// 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 };
}