diff --git a/web/src/components/alerts/IncidentDetailDrawer.spec.ts b/web/src/components/alerts/IncidentDetailDrawer.spec.ts
index f3e68fe97d..a32629e238 100644
--- a/web/src/components/alerts/IncidentDetailDrawer.spec.ts
+++ b/web/src/components/alerts/IncidentDetailDrawer.spec.ts
@@ -44,13 +44,14 @@ vi.mock("@/services/service_streams", () => ({
default: {
getSemanticGroups: vi.fn(),
},
+ buildChipDimensionsFromFilters: vi.fn(() => ({})),
}));
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { mount, flushPromises, VueWrapper } from "@vue/test-utils";
import IncidentDetailDrawer from "./IncidentDetailDrawer.vue";
import incidentsService, { Incident, IncidentWithAlerts, IncidentAlert } from "@/services/incidents";
-import serviceStreamsApi from "@/services/service_streams";
+import serviceStreamsApi, { buildChipDimensionsFromFilters } from "@/services/service_streams";
import { nextTick } from "vue";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
@@ -123,7 +124,7 @@ describe("IncidentDetailDrawer.vue", () => {
TelemetryCorrelationDashboard: {
name: 'TelemetryCorrelationDashboard',
template: '
',
- props: ['mode', 'externalActiveTab', 'serviceName', 'matchedDimensions', 'additionalDimensions', 'logStreams', 'metricStreams', 'traceStreams', 'timeRange', 'hideDimensionFilters']
+ props: ['mode', 'externalActiveTab', 'serviceName', 'matchedDimensions', 'additionalDimensions', 'matchedSetId', 'chipDimensions', 'logStreams', 'metricStreams', 'traceStreams', 'timeRange', 'hideDimensionFilters']
},
CorrelatedLogsTable: {
name: 'CorrelatedLogsTable',
@@ -1235,6 +1236,90 @@ describe("IncidentDetailDrawer.vue", () => {
// Testing child component prop bindings through complex conditional rendering is problematic
// These should be tested through computed properties and direct prop assertions
+ describe("subject tabs (View by) — matchedSetId / chipDimensions", () => {
+ // Regression: the embedded TelemetryCorrelationDashboard never received
+ // matchedSetId/chipDimensions, so its "View by" subject toggle never rendered.
+ const correlatedStreamsWithSet = () => ({
+ serviceName: "checkout",
+ matchedDimensions: { "k8s-pod-name": "pod-abc" },
+ additionalDimensions: {},
+ logStreams: [],
+ metricStreams: [
+ { stream_name: "k8s_metrics", stream_type: "Metrics", filters: { k8s_pod_name: "pod-abc" } },
+ ],
+ traceStreams: [],
+ correlationData: {
+ service_name: "checkout",
+ matched_dimensions: { "k8s-pod-name": "pod-abc" },
+ additional_dimensions: {},
+ related_streams: {
+ logs: [],
+ metrics: [
+ { stream_name: "k8s_metrics", stream_type: "Metrics", filters: { k8s_pod_name: "pod-abc" } },
+ ],
+ traces: [],
+ },
+ matched_set_id: "kubernetes",
+ },
+ });
+
+ beforeEach(async () => {
+ (buildChipDimensionsFromFilters as any).mockReturnValue({ k8s_pod_name: "pod-abc" });
+ wrapper = await createWrapper({}, {}, "1");
+ await nextTick();
+ await flushPromises();
+ });
+
+ it("should expose matched_set_id as correlationMatchedSetId after correlation loads", async () => {
+ (incidentsService.getCorrelatedStreams as any).mockResolvedValue(correlatedStreamsWithSet());
+
+ await wrapper.vm.refreshCorrelation();
+ await flushPromises();
+
+ expect(wrapper.vm.correlationMatchedSetId).toBe("kubernetes");
+ });
+
+ it("should build chipDimensions from the correlation response and semantic groups", async () => {
+ (incidentsService.getCorrelatedStreams as any).mockResolvedValue(correlatedStreamsWithSet());
+
+ await wrapper.vm.refreshCorrelation();
+ await flushPromises();
+
+ // Access the (lazy) computed first so it evaluates and invokes the helper.
+ expect(wrapper.vm.correlationChipDimensions).toEqual({ k8s_pod_name: "pod-abc" });
+
+ const resp = correlatedStreamsWithSet().correlationData;
+ expect(buildChipDimensionsFromFilters).toHaveBeenCalledWith(
+ expect.objectContaining({ matched_set_id: resp.matched_set_id }),
+ expect.any(Array),
+ );
+ });
+
+ it("should yield undefined matchedSetId and empty chipDimensions when no correlation data", () => {
+ expect(wrapper.vm.correlationData).toBeNull();
+ expect(wrapper.vm.correlationMatchedSetId).toBeUndefined();
+ expect(wrapper.vm.correlationChipDimensions).toEqual({});
+ });
+
+ it("should pass matchedSetId/chipDimensions to the metrics TelemetryCorrelationDashboard", async () => {
+ (incidentsService.getCorrelatedStreams as any).mockResolvedValue(correlatedStreamsWithSet());
+
+ await wrapper.vm.refreshCorrelation();
+ // activeTab is the tabs v-model; set it directly since reaching the
+ // metrics panel via DOM tab clicks depends on brittle conditional markup.
+ wrapper.vm.activeTab = "metrics";
+ await nextTick();
+ await flushPromises();
+
+ const dashboard = wrapper
+ .findAllComponents({ name: "TelemetryCorrelationDashboard" })
+ .find((c) => c.props("externalActiveTab") === "metrics");
+ expect(dashboard?.exists()).toBe(true);
+ expect(dashboard?.props("matchedSetId")).toBe("kubernetes");
+ expect(dashboard?.props("chipDimensions")).toEqual({ k8s_pod_name: "pod-abc" });
+ });
+ });
+
// Note: Loading State Centering tests removed
// Testing loading state UI presentation is better handled through visual regression tests
// Component logic tests should focus on loading state management, not CSS/layout
diff --git a/web/src/components/alerts/IncidentDetailDrawer.vue b/web/src/components/alerts/IncidentDetailDrawer.vue
index 187b5791ba..d3a435551e 100644
--- a/web/src/components/alerts/IncidentDetailDrawer.vue
+++ b/web/src/components/alerts/IncidentDetailDrawer.vue
@@ -1082,6 +1082,8 @@ along with this program. If not, see .
:serviceName="correlationData.serviceName"
:matchedDimensions="correlationData.matchedDimensions"
:additionalDimensions="correlationData.additionalDimensions"
+ :matched-set-id="correlationMatchedSetId"
+ :chip-dimensions="correlationChipDimensions"
:logStreams="correlationData.logStreams"
:metricStreams="correlationData.metricStreams"
:traceStreams="correlationData.traceStreams"
@@ -1174,7 +1176,9 @@ import incidentsService, {
IncidentCorrelatedStreams,
} from "@/services/incidents";
import streamService from "@/services/stream";
-import serviceStreamsApi from "@/services/service_streams";
+import serviceStreamsApi, {
+ buildChipDimensionsFromFilters,
+} from "@/services/service_streams";
import { getImageURL } from "@/utils/zincutils";
import { marked } from 'marked';
import DOMPurify from 'dompurify';
@@ -1324,6 +1328,19 @@ export default defineComponent({
return semanticGroupDisplayMap.value.get(id) || id;
};
+ // Identity set + chip dimensions powering the "View by" subject tabs in the
+ // embedded TelemetryCorrelationDashboard (metrics tab). Without matchedSetId
+ // the dashboard cannot build subject chips, so the toggle never renders.
+ // Both derive from the correlate API response; chip dimensions stay reactive
+ // to semanticGroups (used only for dedup) so they refresh if groups load late.
+ const correlationMatchedSetId = computed(
+ () => correlationData.value?.correlationData?.matched_set_id ?? undefined,
+ );
+ const correlationChipDimensions = computed>(() => {
+ const resp = correlationData.value?.correlationData;
+ return resp ? buildChipDimensionsFromFilters(resp, semanticGroups.value) : {};
+ });
+
// True when a background AI analysis run has started but not yet completed.
// Updated whenever events are fetched (load, tab switch, reopen, etc.) — no polling.
const analysisInFlight = ref(false);
@@ -2867,6 +2884,8 @@ export default defineComponent({
expandedSections,
formattedRcaContent,
correlationData,
+ correlationMatchedSetId,
+ correlationChipDimensions,
correlationLoading,
correlationError,
hasCorrelatedData,
diff --git a/web/src/plugins/correlation/CorrelationEventHeader.spec.ts b/web/src/plugins/correlation/CorrelationEventHeader.spec.ts
new file mode 100644
index 0000000000..1069df1c61
--- /dev/null
+++ b/web/src/plugins/correlation/CorrelationEventHeader.spec.ts
@@ -0,0 +1,125 @@
+// 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 { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { mount, VueWrapper } from "@vue/test-utils";
+import CorrelationEventHeader from "./CorrelationEventHeader.vue";
+
+// Minimal O2 stubs — these specs assert structure/behaviour, not O2 internals.
+const OBadgeStub = {
+ name: "OBadge",
+ props: ["variant", "size", "dot"],
+ template: ``,
+};
+const OSeparatorStub = { name: "OSeparator", template: `
` };
+const OTooltipStub = {
+ name: "OTooltip",
+ props: ["content", "side", "disabled"],
+ template: ``,
+};
+const OToggleGroupStub = {
+ name: "OToggleGroup",
+ props: ["modelValue", "type", "size"],
+ emits: ["update:modelValue"],
+ template: `
`,
+};
+const OToggleGroupItemStub = {
+ name: "OToggleGroupItem",
+ props: ["value", "size", "disabled"],
+ template: ``,
+};
+
+const subjectChip = (over: Record = {}) => ({
+ key: "k8s-pod-name",
+ label: "Pod",
+ value: "pod-abc",
+ kind: "subject" as const,
+ active: false,
+ ...over,
+});
+
+describe("CorrelationEventHeader.vue", () => {
+ let wrapper: VueWrapper;
+
+ const createWrapper = (props = {}) =>
+ mount(CorrelationEventHeader, {
+ props: {
+ subjectChips: [subjectChip()],
+ activeSubject: "k8s-pod-name",
+ ...props,
+ },
+ global: {
+ stubs: {
+ OBadge: OBadgeStub,
+ OSeparator: OSeparatorStub,
+ OTooltip: OTooltipStub,
+ OToggleGroup: OToggleGroupStub,
+ OToggleGroupItem: OToggleGroupItemStub,
+ },
+ },
+ });
+
+ afterEach(() => {
+ wrapper?.unmount();
+ vi.clearAllMocks();
+ });
+
+ describe("selected-subject badge", () => {
+ it("should render a 'label = value' badge for the active subject when it has a value", () => {
+ wrapper = createWrapper();
+ const badge = wrapper.find(
+ '[data-test="correlation-event-header-active-subject-k8s-pod-name"]',
+ );
+ expect(badge.exists()).toBe(true);
+ expect(badge.text()).toBe("Pod = pod-abc");
+ });
+
+ it("should not render the badge when the active subject has no value (incident case)", () => {
+ wrapper = createWrapper({
+ subjectChips: [subjectChip({ value: "" })],
+ activeSubject: "k8s-pod-name",
+ });
+ expect(
+ wrapper
+ .find('[data-test="correlation-event-header-active-subject-k8s-pod-name"]')
+ .exists(),
+ ).toBe(false);
+ });
+
+ it("should not render the badge when no subject is active", () => {
+ wrapper = createWrapper({ activeSubject: null });
+ expect(
+ wrapper
+ .find('[data-test="correlation-event-header-active-subject-k8s-pod-name"]')
+ .exists(),
+ ).toBe(false);
+ });
+
+ it("should render the badge for whichever subject is active", () => {
+ wrapper = createWrapper({
+ subjectChips: [
+ subjectChip(),
+ subjectChip({ key: "k8s-node-name", label: "Node", value: "node-1" }),
+ ],
+ activeSubject: "k8s-node-name",
+ });
+ const badge = wrapper.find(
+ '[data-test="correlation-event-header-active-subject-k8s-node-name"]',
+ );
+ expect(badge.exists()).toBe(true);
+ expect(badge.text()).toBe("Node = node-1");
+ });
+ });
+});
diff --git a/web/src/plugins/correlation/CorrelationEventHeader.vue b/web/src/plugins/correlation/CorrelationEventHeader.vue
index d2a80556f9..5a74005cb8 100644
--- a/web/src/plugins/correlation/CorrelationEventHeader.vue
+++ b/web/src/plugins/correlation/CorrelationEventHeader.vue
@@ -45,14 +45,15 @@ along with this program. If not, see .
-
+
Correlated by:
@@ -74,13 +75,24 @@ along with this program. If not, see
.
class="tw:cursor-default"
:data-test="`correlation-event-header-overflow-${hiddenChipCount}`"
>
- +{{ hiddenChipCount }}
+
+{{ hiddenChipCount }}
Fields
-
+
+
+
+
+ {{ chip.label }} = {{ chip.value }}
+
+
+
+
@@ -118,6 +130,17 @@ along with this program. If not, see
.
+
+
+
+ {{ activeSubjectChip.label }} = {{ activeSubjectChip.value }}
+
@@ -130,6 +153,7 @@ along with this program. If not, see .