Files
openobserve/web/src/utils/telemetryCorrelation.ts
T
Ashish Kolhe bafc2be4f0 fix(correlation): P0/P1 fixes from service-streams correlation review (#13668)
## Summary

OSS half of the correlation P0/P1 fix set from the line-by-line review
(`docs/ak_review/correlation-line-by-line-review-2026-08-06.md`).
Companion enterprise PR: same branch name in o2-enterprise (required for
CI manifest pairing).

### Fixes (by review finding)

| Finding | Fix |
|---|---|
| **F14** | `StreamInfo.dropped_dimensions` contract field
(`#[serde(default, skip_serializing_if)]`, backward-compatible wire
format) + TS type |
| **F8** | Shared coverage-based identity-set ranking
(`resolve_best_set_by_coverage`) in `config::meta::correlation` — full
coverage > ratio > count, declaration-order ties; ingest and read now
rank identically |
| **F10** | `save_identity_config` purges rows of removed/changed sets
(`obsolete_set_ids`) + cache clear + org reload event |
| **F6** | `_reset` routes through enterprise
`ServiceStorage::delete_all` (cache clear + super-cluster replication)
and emits an org-scope reload; super-cluster Delete consumer emits org
reload instead of per-service delete |
| **F19** | `infra::table::service_streams::put`/`delete_subset_orphans`
return deleted orphan disambiguations so callers can evict cache keys |
| **F2** | `config::utils::sql::validate_where_fragment` (sqlparser)
applied to the `filter` param at both `traces/latest*` interpolation
sites — statement smuggling and comment-truncation payloads → 400 |
| **F2/F38** | FE: shared
`quoteSqlIdentifier`/`quoteSqlLiteral`/`buildSqlCondition` adopted by
all six SQL builders (two trace paths were fully unescaped) |
| **F35** | `applyFilterOverlay`: filter edits resolve per-stream via
semantic groups instead of literal key identity; dead
`removeFilter`/`resetFilters` removed; `semanticGroups` threaded to
`CorrelatedLogsTable` parents |
| **F36** | `applyDimensionEditsToFilters`: dimension-bar edits work in
both key spaces (raw-field and semantic-ID), case-insensitive shared
field→group map |
| **F31** | `mergeSubjectOverrides`: subject overrides replace the
same-group key (never union two aliases into contradictory WHERE),
schema-less field guessing removed |
| F14 (FE) | Warning banner shipped, then removed by reviewer decision
(`dropped_dimensions` stays in the API) |

### Verification

- `cargo test -p config` correlation/service_streams/sql: 121 tests
green; enterprise-feature and OSS-only cfg builds both compile
- FE: 263 vitest tests green across the correlation suites; vue-tsc
clean
- **Live-verified** against a local build (fresh DB): 19/19 API checks
(raw-value emission, set filing, obsolete-set purge, reset cache
invalidation, orphan eviction, filter validation) plus browser checks
(escaped SQL on the wire: `"environment" = 'Prod-EU'`)

Deliberately out of scope (per review decision): F15, F17/F37, F9.
2026-08-06 11:49:39 +00:00

532 lines
18 KiB
TypeScript

// 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 <http://www.gnu.org/licenses/>.
import type {
ServiceMetadata,
FieldAlias,
CorrelationResponse,
StreamInfo,
ServiceIdentityConfig,
} from "@/services/service_streams";
import { SELECT_ALL_VALUE } from "@/utils/dashboard/constants";
/**
* Telemetry Correlation Utilities
*
* Tools for correlating logs, traces, and metrics using service_streams data
*/
export type TelemetryType = "logs" | "traces" | "metrics";
export interface TelemetryContext {
timestamp: number;
fields: Record<string, any>;
streamName?: string;
}
export interface CorrelationQuery {
type: TelemetryType;
stream: string;
sql: string;
filters: Record<string, any>;
timeRange: {
start: number;
end: number;
};
}
export interface CorrelationResult {
service: ServiceMetadata;
queries: CorrelationQuery[];
correlationData?: CorrelationResponse; // New: detailed correlation info from API
}
/**
* Extract semantic dimensions from telemetry context
*
* Maps raw field names to semantic dimension IDs using semantic groups.
*
* @param context - The telemetry context containing fields
* @param semanticGroups - Semantic field group definitions
* @param stableOnly - If true, only extract stable dimensions (for correlation).
* Unstable dimensions (pod-id, pod-start-time, etc.) change
* on pod restarts and should not be used for service matching.
*/
export function extractSemanticDimensions(
context: TelemetryContext,
semanticGroups: FieldAlias[],
stableOnly: boolean = false,
): Record<string, string> {
const dimensions: Record<string, string> = {};
// Iterate groups and their fields in definition order (same as backend processor.rs).
// First field found in context.fields wins — deterministic, matches ingestion behaviour.
for (const group of semanticGroups) {
if (stableOnly && !(group.is_stable ?? false)) {
continue;
}
for (const field of group.fields) {
const value = context.fields[field];
if (value !== null && value !== undefined) {
dimensions[group.id] = String(value);
break; // first match by group definition order wins
}
}
}
return dimensions;
}
/**
* Build a reverse mapping from field name (lowercase) to schematic group ID.
* Uses definition-order priority: first FieldAlias that includes the field wins.
* This is the inverse of extractSemanticDimensions.
*
* Use this to deduplicate fields by their semantic group when building queries
* from multiple correlated streams that may use different field names for the
* same conceptual dimension.
*/
export function buildFieldToGroupIdMap(semanticGroups: FieldAlias[]): Map<string, string> {
const map = new Map<string, string>();
for (const group of semanticGroups) {
for (const field of group.fields) {
const lower = field.toLowerCase();
if (!map.has(lower)) {
map.set(lower, group.id);
}
}
}
return map;
}
/**
* Overlay user filter edits onto ONE stream's base filters (F35).
*
* Chip keys are raw field names harvested from a single stream, but every
* correlated stream carries its own alias for the same semantic group (e.g.
* a log stream's `k8s_namespace_name` vs a trace stream's
* `service_k8s_namespace_name`). Requiring literal key identity silently drops
* the user's edit for every stream that spells the field differently, so the
* override is resolved through the semantic group when the exact key is absent.
*
* Precedence: an exact key match always wins; only then do we fall back to the
* group. Overrides that match neither are dropped — adding them would emit a
* WHERE condition on a column the stream does not have.
*
* @param baseFilters - the stream's own filters, keyed by its raw field names
* @param overrides - user-edited values, keyed by whichever stream's field names the chips came from
* @param fieldToGroupId - lowercased field name -> semantic group ID (see buildFieldToGroupIdMap)
*/
export function applyFilterOverlay(
baseFilters: Record<string, string>,
overrides: Record<string, string>,
fieldToGroupId: Map<string, string>,
): Record<string, string> {
const effective: Record<string, string> = { ...baseFilters };
for (const [key, value] of Object.entries(overrides)) {
if (key in baseFilters) {
effective[key] = value;
continue;
}
const groupId = fieldToGroupId.get(key.toLowerCase());
if (!groupId) continue;
const target = Object.keys(baseFilters).find(
(baseKey) => fieldToGroupId.get(baseKey.toLowerCase()) === groupId,
);
if (target) effective[target] = value;
}
return effective;
}
/**
* Apply dimension-bar edits to ONE stream's filters (F36).
*
* `activeDimensions` is raw-field-keyed when the bar was seeded from stream
* filters (SearchResult / TraceDetailsSidebar dialog path) and
* semantic-ID-keyed when seeded from `matched_dimensions`
* (IncidentDetailDrawer path) — the component cannot tell which, so look up
* BOTH key spaces, raw field first.
*
* Only existing filter keys are updated: adding a key would emit a WHERE
* condition on a column the stream does not have.
*
* @param filters - the stream's own filters, keyed by its raw field names
* @param activeDimensions - edited values, keyed by raw field name OR semantic group ID
* @param fieldToDimensionId - lowercased field name -> semantic group ID (see buildFieldToGroupIdMap)
*/
export function applyDimensionEditsToFilters(
filters: Record<string, string>,
activeDimensions: Record<string, string>,
fieldToDimensionId: Map<string, string>,
): Record<string, string> {
const updated: Record<string, string> = { ...filters };
for (const filterKey of Object.keys(filters)) {
const dimensionId = fieldToDimensionId.get(filterKey.toLowerCase());
// Raw-field key wins over the semantic-ID key. When the bar was seeded from
// a different stream's alias, fall back to any edit whose key resolves to
// the same semantic group.
let newValue = activeDimensions[filterKey];
if (newValue === undefined && dimensionId !== undefined) {
newValue = activeDimensions[dimensionId];
}
if (newValue === undefined && dimensionId !== undefined) {
const aliasKey = Object.keys(activeDimensions).find(
(key) => fieldToDimensionId.get(key.toLowerCase()) === dimensionId,
);
if (aliasKey !== undefined) newValue = activeDimensions[aliasKey];
}
if (newValue !== undefined) {
updated[filterKey] = newValue;
}
}
return updated;
}
/**
* Merge schema-verified subject overrides into ONE stream's filters (F31).
*
* The backend resolves a stream's filters using its own alias for a semantic
* group; the subject override is resolved against the stream schema and may
* land on a different alias of the SAME group. A plain spread therefore UNIONS
* the two aliases into `WHERE old_alias = 'prod' AND new_alias = 'staging'`,
* which matches nothing. Replace the same-group key instead of adding to it.
*
* An override whose group has no counterpart in `filters` is added — it was
* resolved against the stream schema, so the column is known to exist.
*
* @param filters - the stream's own filters, keyed by its raw field names
* @param overrides - schema-verified field name -> value
* @param fieldToGroupId - lowercased field name -> semantic group ID (see buildFieldToGroupIdMap)
*/
export function mergeSubjectOverrides(
filters: Record<string, string>,
overrides: Record<string, string>,
fieldToGroupId: Map<string, string>,
): Record<string, string> {
const effective: Record<string, string> = { ...filters };
for (const [hit, value] of Object.entries(overrides)) {
const groupId = fieldToGroupId.get(hit.toLowerCase());
const existingKey = Object.keys(effective).find(
(key) =>
key === hit || (groupId !== undefined && fieldToGroupId.get(key.toLowerCase()) === groupId),
);
if (existingKey !== undefined && existingKey !== hit) {
delete effective[existingKey];
}
effective[hit] = value;
}
return effective;
}
/**
* Filter dimensions to only include fields that are actually used for disambiguation
*
* This implements the same logic as the backend to determine which dimensions are relevant
* for service matching. This reduces the amount of data sent to the _correlate API and
* ensures we only send dimensions that the backend will actually use.
*
* Logic matches backend processor.rs:
* 1. Use distinguish_by fields from all identity sets (union), OR
* 2. Use tracked_alias_ids as fallback
* 3. Always include "service" dimension if present
*
* @param allDimensions - All extracted semantic dimensions
* @param identityConfig - Service identity configuration
* @returns Filtered dimensions containing only disambiguation fields
*/
export function filterDimensionsForCorrelation(
allDimensions: Record<string, string>,
identityConfig: ServiceIdentityConfig,
): Record<string, string> {
// Determine selected fields (same logic as backend)
let selectedFields: string[] = [];
if (identityConfig.sets && identityConfig.sets.length > 0) {
// Use distinguish_by fields from ALL identity sets (union of all sets)
const allDistinguishByFields = new Set<string>();
for (const set of identityConfig.sets) {
if (set.distinguish_by) {
set.distinguish_by.forEach((field) => allDistinguishByFields.add(field));
}
}
selectedFields = Array.from(allDistinguishByFields);
} else if (identityConfig.tracked_alias_ids && identityConfig.tracked_alias_ids.length > 0) {
// Fallback to tracked_alias_ids
selectedFields = identityConfig.tracked_alias_ids;
} else {
// No config available, return all dimensions
return allDimensions;
}
// Include selected fields. Add "service" only when not in service_optional mode —
// including it would make the backend take the service-name fast path and ignore the
// service_optional toggle (which only triggers when `service` is absent from input).
const fieldsToKeep = new Set<string>(selectedFields);
if (!identityConfig.service_optional) {
fieldsToKeep.add("service");
}
// Filter dimensions to only include fields we need
const filtered = Object.fromEntries(
Object.entries(allDimensions).filter(([key]) => fieldsToKeep.has(key)),
);
return filtered;
}
/**
* SQL escaping for correlation query builders. Values originate from
* telemetry labels (user-controlled data) — every builder MUST route
* field/value interpolation through these helpers (F2).
*/
export function quoteSqlIdentifier(field: string): string {
return `"${String(field).replace(/"/g, '""')}"`;
}
export function quoteSqlLiteral(value: string): string {
return `'${String(value).replace(/'/g, "''")}'`;
}
export function buildSqlCondition(field: string, value: string): string {
return `${quoteSqlIdentifier(field)} = ${quoteSqlLiteral(value)}`;
}
/**
* Build WHERE clause conditions using exact field names from StreamInfo.filters
*
* Uses the exact field names returned by the _correlate API instead of semantic variations.
* Skips filters with SELECT_ALL_VALUE (wildcard - means match all values).
*/
function buildExactDimensionConditions(filters: Record<string, string>): string[] {
const conditions: string[] = [];
for (const [fieldName, value] of Object.entries(filters)) {
// Skip SELECT_ALL_VALUE wildcards - these mean "match all values"
if (value === SELECT_ALL_VALUE) {
continue;
}
conditions.push(buildSqlCondition(fieldName, value));
}
return conditions;
}
/**
* Generate trace query using exact field names from StreamInfo
* Timestamp is NOT included in SQL - it's passed as separate from/to query params
*
* Uses only the filters from StreamInfo (same as logs/metrics) - service_name
* is already included in filters if present.
*/
export function buildTraceQuery(
streamInfo: StreamInfo,
context: TelemetryContext,
timeWindowMinutes: number = 5,
matchedDimensions: Record<string, string> = {},
): CorrelationQuery {
const conditions: string[] = [];
const dimensionConditions = buildExactDimensionConditions(matchedDimensions);
conditions.push(...dimensionConditions);
// Build SQL WITHOUT timestamp (timestamp passed separately as from/to)
let sql = `SELECT * FROM "${streamInfo.stream_name}"`;
if (conditions.length > 0) {
sql += `\nWHERE ${conditions.join("\n AND ")}`;
}
sql += `\nORDER BY _timestamp DESC\nLIMIT 100`;
// Calculate time range
const timeWindowMicros = timeWindowMinutes * 60 * 1000000;
const startTime = context.timestamp - timeWindowMicros;
const endTime = context.timestamp + timeWindowMicros;
return {
type: "traces",
stream: streamInfo.stream_name,
sql,
filters: matchedDimensions,
timeRange: {
start: startTime,
end: endTime,
},
};
}
/**
* Generate metrics query using exact field names from StreamInfo
* Timestamp is NOT included in SQL - it's passed as separate from/to query params
*
* StreamInfo.filters contains the actual labels that exist in this specific metric stream.
* These were captured when the metric was processed and stored.
*/
export function buildMetricQuery(
streamInfo: StreamInfo,
context: TelemetryContext,
timeWindowMinutes: number = 5,
matchedDimensions: Record<string, string> = {},
): CorrelationQuery {
const conditions: string[] = [];
const dimensionConditions = buildExactDimensionConditions(matchedDimensions);
conditions.push(...dimensionConditions);
// Build SQL WITHOUT timestamp (timestamp passed separately as from/to)
let sql = `SELECT * FROM "${streamInfo.stream_name}"`;
if (conditions.length > 0) {
sql += `\nWHERE ${conditions.join("\n AND ")}`;
}
sql += `\nORDER BY _timestamp DESC\nLIMIT 100`;
// Calculate time range
const timeWindowMicros = timeWindowMinutes * 60 * 1000000;
const startTime = context.timestamp - timeWindowMicros;
const endTime = context.timestamp + timeWindowMicros;
return {
type: "metrics",
stream: streamInfo.stream_name,
sql,
filters: matchedDimensions,
timeRange: {
start: startTime,
end: endTime,
},
};
}
/**
* Generate logs query using exact field names from StreamInfo
* Timestamp is NOT included in SQL - it's passed as separate from/to query params
*/
export function buildLogQuery(
streamInfo: StreamInfo,
context: TelemetryContext,
timeWindowMinutes: number = 5,
matchedDimensions: Record<string, string> = {},
): CorrelationQuery {
const conditions: string[] = [];
const dimensionConditions = buildExactDimensionConditions(matchedDimensions);
conditions.push(...dimensionConditions);
// Build SQL WITHOUT timestamp (timestamp passed separately as from/to)
let sql = `SELECT * FROM "${streamInfo.stream_name}"`;
if (conditions.length > 0) {
sql += `\nWHERE ${conditions.join("\n AND ")}`;
}
sql += `\nORDER BY _timestamp DESC\nLIMIT 100`;
// Calculate time range
const timeWindowMicros = timeWindowMinutes * 60 * 1000000;
const startTime = context.timestamp - timeWindowMicros;
const endTime = context.timestamp + timeWindowMicros;
return {
type: "logs",
stream: streamInfo.stream_name,
sql,
filters: matchedDimensions,
timeRange: {
start: startTime,
end: endTime,
},
};
}
/**
* Generate all correlation queries using data from the _correlate API
*
* This function uses the StreamInfo objects returned by the API which contain
* exact field names for each stream, ensuring queries match actual field names.
*/
export function generateCorrelationQueries(
service: ServiceMetadata,
context: TelemetryContext,
sourceType: TelemetryType,
semanticGroups: FieldAlias[],
timeWindowMinutes: number = 5,
correlationData?: CorrelationResponse,
): CorrelationQuery[] {
const queries: CorrelationQuery[] = [];
// If we have correlation data from the API, use matched_dimensions for WHERE clauses
if (correlationData) {
const dims = correlationData.matched_dimensions ?? {};
if (sourceType !== "traces") {
for (const streamInfo of correlationData.related_streams.traces) {
queries.push(buildTraceQuery(streamInfo, context, timeWindowMinutes, dims));
}
}
if (sourceType !== "metrics") {
for (const streamInfo of correlationData.related_streams.metrics) {
queries.push(buildMetricQuery(streamInfo, context, timeWindowMinutes, dims));
}
}
if (sourceType !== "logs") {
for (const streamInfo of correlationData.related_streams.logs) {
queries.push(buildLogQuery(streamInfo, context, timeWindowMinutes, dims));
}
}
}
return queries;
}
/**
* Find matching service by dimensions
*
* Searches through services to find ones matching extracted dimensions
*/
export function findMatchingService(
services: ServiceMetadata[],
dimensions: Record<string, string>,
): ServiceMetadata | null {
// Find service with most matching dimensions
let bestMatch: ServiceMetadata | null = null;
let bestMatchScore = 0;
for (const service of services) {
let matchScore = 0;
for (const [dimId, dimValue] of Object.entries(dimensions)) {
if (service.dimensions[dimId] === dimValue) {
matchScore++;
}
}
// Require at least one dimension match
if (matchScore > 0 && matchScore > bestMatchScore) {
bestMatch = service;
bestMatchScore = matchScore;
}
}
return bestMatch;
}