// Copyright 2026 OpenObserve Inc. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { loadIdentityConfig } from "@/utils/identityConfig"; import serviceStreamsApi from "@/services/service_streams"; import type { FieldAlias } from "@/services/service_streams"; import config from "@/aws-exports"; function isCorrelationAvailable(): boolean { return config.isEnterprise === "true" || config.isCloud === "true"; } // ── Cache ──────────────────────────────────────────────────────────────────── const fieldNamesCache = new Map(); const semanticGroupsCache = new Map(); let lastSeenOrgId: string | null = null; function evictOrgCacheIfSwitched(orgId: string): void { if (lastSeenOrgId !== null && lastSeenOrgId !== orgId) { // Org switched — remove all cached entries for the previous org so stale // identity config and semantic groups are not reused. for (const key of fieldNamesCache.keys()) { if (key.startsWith(`${lastSeenOrgId}/`)) fieldNamesCache.delete(key); } semanticGroupsCache.delete(lastSeenOrgId); } lastSeenOrgId = orgId; } async function loadSemanticGroups(orgId: string): Promise { if (!isCorrelationAvailable()) return []; if (semanticGroupsCache.has(orgId)) return semanticGroupsCache.get(orgId)!; try { const response = await serviceStreamsApi.getSemanticGroups(orgId); const groups: FieldAlias[] = response.data ?? []; semanticGroupsCache.set(orgId, groups); return groups; } catch { return []; } } // ── Field resolution ───────────────────────────────────────────────────────── export async function getCorrelationFieldNames( orgId: string, streamName: string, streamSchemaFields: { name: string }[], ): Promise { if (!isCorrelationAvailable()) return []; evictOrgCacheIfSwitched(orgId); const key = `${orgId}/${streamName}`; if (fieldNamesCache.has(key)) return fieldNamesCache.get(key)!; let identityConfig: any; try { identityConfig = await loadIdentityConfig(orgId); } catch { return []; } const trackedIds: string[] = identityConfig?.tracked_alias_ids; if (!trackedIds?.length) return []; const semanticGroups = await loadSemanticGroups(orgId); const idToFields = new Map(); for (const group of semanticGroups) idToFields.set(group.id, group.fields); const schemaFieldNames = new Set(streamSchemaFields.map((f) => f.name)); const matched: string[] = []; for (const aliasId of trackedIds) { const fieldNames = idToFields.get(aliasId); if (fieldNames) { for (const fieldName of fieldNames) { if (schemaFieldNames.has(fieldName)) matched.push(fieldName); } } else if (schemaFieldNames.has(aliasId)) { matched.push(aliasId); } } fieldNamesCache.set(key, matched); return matched; } // ── Storage ─────────────────────────────────────────────────────────────────── const STORAGE_PREFIX = "oo_correlation_filters"; function storageKey(orgId: string, streamType: string, streamName: string): string { return `${STORAGE_PREFIX}_${orgId}_${streamType}_${streamName}`; } export interface SavedFilter { field: string; value: string; } export function extractCorrelationFilters( queryStr: string, trackedFieldNames: string[], ): SavedFilter[] { if (!queryStr || !trackedFieldNames.length) return []; const trackedSet = new Set(trackedFieldNames); const filters: SavedFilter[] = []; // Strip SELECT ... FROM "stream" WHERE prefix for SQL mode queries let whereClause = queryStr; const whereIdx = queryStr.search(/WHERE\s+/i); if (whereIdx !== -1) { whereClause = queryStr.slice(whereIdx).replace(/^WHERE\s+/i, ""); } // Match field = 'value' where value may contain SQL-escaped single quotes ('') const conditionRegex = /(\w+)\s*=\s*'((?:[^']|'')*)'/gi; let m: RegExpExecArray | null; while ((m = conditionRegex.exec(whereClause)) !== null) { const field = m[1]; if (trackedSet.has(field)) { const value = m[2].replace(/''/g, "'"); const existing = filters.findIndex((f) => f.field === field); if (existing >= 0) { filters[existing].value = value; } else { filters.push({ field, value }); } } } return filters; } export function saveCorrelationFilters( orgId: string, streamType: string, streamName: string, filters: SavedFilter[], ): void { if (!orgId || !streamType || !streamName || !filters.length) return; localStorage.setItem(storageKey(orgId, streamType, streamName), JSON.stringify(filters)); } export function loadCorrelationFilters( orgId: string, streamType: string, streamName: string, ): SavedFilter[] { try { const raw = localStorage.getItem(storageKey(orgId, streamType, streamName)); if (!raw) return []; return JSON.parse(raw); } catch { return []; } } export function buildCorrelationWhereClause(filters: SavedFilter[]): string { if (!filters.length) return ""; return filters.map((f) => `${f.field} = '${f.value.replace(/'/g, "''")}'`).join(" AND "); } export function clearCorrelationFilters( orgId: string, streamType: string, streamName: string, ): void { localStorage.removeItem(storageKey(orgId, streamType, streamName)); } export function clearCorrelationCache(): void { fieldNamesCache.clear(); semanticGroupsCache.clear(); lastSeenOrgId = null; } // ── Generic composable ──────────────────────────────────────────────────────── import { watch, type WatchSource } from "vue"; export interface CorrelationFiltersOptions { orgId: () => string; streamType: () => string; streamName: () => string; streamSchemaFields: () => { name: string }[]; getQuery: () => string; setQuery: (whereClause: string) => void; /** Reactive source to watch for query changes (e.g. () => searchObj.data.query) */ querySource?: WatchSource; } export function useCorrelationFilters(opts: CorrelationFiltersOptions) { // Tracks which (org, streamType, streamName) keys we've already attempted to // restore for in this session. Without this, every extractFields() call // (which happens after every query) would re-apply saved filters whenever // the query is empty — racing with user actions like deselecting all values // in the field sidebar and silently restoring filters the user just cleared. const restoredKeys = new Set(); const sync = async (queryStr: string): Promise => { try { const orgId = opts.orgId(); const streamType = opts.streamType(); const streamName = opts.streamName(); if (!orgId || !streamType || !streamName) return; if (!queryStr) { clearCorrelationFilters(orgId, streamType, streamName); return; } const trackedFields = await getCorrelationFieldNames( orgId, streamName, opts.streamSchemaFields(), ); if (!trackedFields.length) return; const filters = extractCorrelationFilters(queryStr, trackedFields); if (filters.length) { saveCorrelationFilters(orgId, streamType, streamName, filters); } else { clearCorrelationFilters(orgId, streamType, streamName); } } catch (e) { console.error("[correlation:sync] error:", e); } }; const save = (): Promise => sync(opts.getQuery()); const restore = (): void => { try { const orgId = opts.orgId(); const streamType = opts.streamType(); const streamName = opts.streamName(); if (!orgId || !streamType || !streamName) return; const key = `${orgId}|${streamType}|${streamName}`; if (restoredKeys.has(key)) return; restoredKeys.add(key); if (opts.getQuery()) return; const saved = loadCorrelationFilters(orgId, streamType, streamName); if (!saved.length) return; const whereClause = buildCorrelationWhereClause(saved); if (!whereClause) return; opts.setQuery(whereClause); } catch (e) { console.error("[correlation:restore] error:", e); } }; // Sets up a watcher so localStorage stays in sync as user edits the query bar. // Debounced to avoid firing on every keystroke. const watchQuery = (): void => { if (!opts.querySource) return; let timer: ReturnType; watch(opts.querySource, (newQuery: string) => { clearTimeout(timer); timer = setTimeout(() => sync(newQuery), 600); }); }; return { save, restore, watchQuery }; }