= {}) =>
+ mount(DetailTable, {
+ attachTo: "#app",
+ props: { ...defaultProps, modelValue: recordWithTraceId, ...props },
+ global: globalMountOptions,
+ });
+
+ beforeEach(() => {
+ // Open the gate: traces menu visible, service streams off, trace id field known.
+ store.state.zoConfig.service_streams_enabled = false;
+ store.state.hiddenMenus = [];
+ store.state.organizationData.organizationSettings.trace_id_field_name = "trace_id";
+
+ getStreams.mockResolvedValue({
+ list: TRACES_STREAMS.map((name) => ({ name })),
+ });
+ });
+
+ afterEach(() => {
+ if (traceWrapper) {
+ traceWrapper.unmount();
+ traceWrapper = null;
+ }
+ });
+
+ it("renders the stream picker and the button when the gate passes", async () => {
+ traceWrapper = mountDetailTable();
+ await flushPromises();
+
+ const select = traceWrapper.find(SELECT);
+ expect(select.exists()).toBe(true);
+ expect(select.findAll("option").map((option: any) => option.text())).toEqual(TRACES_STREAMS);
+ expect(traceWrapper.find(BUTTON).exists()).toBe(true);
+ });
+
+ it("writes the picked stream back to searchObj.meta.selectedTraceStream", async () => {
+ traceWrapper = mountDetailTable();
+ await flushPromises();
+
+ const select = traceWrapper.find(SELECT);
+ select.element.value = "otlp_traces";
+ await select.trigger("change");
+
+ expect(traceWrapper.vm.searchObj.meta.selectedTraceStream).toBe("otlp_traces");
+ });
+
+ it("hides the action when the record carries no trace id", async () => {
+ traceWrapper = mountDetailTable({ modelValue: defaultProps.modelValue });
+ await flushPromises();
+
+ expect(traceWrapper.find(SELECT).exists()).toBe(false);
+ expect(traceWrapper.find(BUTTON).exists()).toBe(false);
+ });
+
+ it("hides the action when the traces menu is hidden for the org", async () => {
+ // `hiddenMenus` is seeded as an array by the store helper, not a Set.
+ store.state.hiddenMenus = ["traces"];
+
+ traceWrapper = mountDetailTable();
+ await flushPromises();
+
+ expect(traceWrapper.find(BUTTON).exists()).toBe(false);
+ });
+
+ it("hides the action when service streams are enabled", async () => {
+ store.state.zoConfig.service_streams_enabled = true;
+
+ traceWrapper = mountDetailTable();
+ await flushPromises();
+
+ expect(traceWrapper.find(BUTTON).exists()).toBe(false);
+ });
+
+ it("hides the action when the org has no traces streams to point at", async () => {
+ getStreams.mockResolvedValue({ list: [] });
+
+ traceWrapper = mountDetailTable();
+ await flushPromises();
+
+ // Gate is `showViewTraceBtn && (tracesStreams.length || isTracesStreamsLoading)`:
+ // once loading settles with an empty list there is nothing to view a trace in.
+ expect(traceWrapper.find(SELECT).exists()).toBe(false);
+ expect(traceWrapper.find(BUTTON).exists()).toBe(false);
+ });
+
+ it("emits view-trace when the header button is clicked", async () => {
+ traceWrapper = mountDetailTable();
+ await flushPromises();
+
+ await traceWrapper.find(BUTTON).trigger("click");
+
+ expect(traceWrapper.emitted()["view-trace"]).toHaveLength(1);
+ });
+
+ it("tells JsonPreview to hide its own copy of the action", async () => {
+ traceWrapper = mountDetailTable();
+ await flushPromises();
+
+ const jsonPreview = traceWrapper.findComponent({ name: "JsonPreview" });
+ expect(jsonPreview.exists()).toBe(true);
+ expect(jsonPreview.props("hideViewTrace")).toBe(true);
+ });
+
+ // REGRESSION: the gate must read `props.modelValue`, which exists during
+ // setup, and not `rowData`, which the async created() hook only fills a
+ // microtask later. Reading rowData left the button permanently hidden.
+ it("shows the action on the first render, before created() has filled rowData", async () => {
+ traceWrapper = mountDetailTable();
+
+ // Nothing has been awaited: created() has not resolved, so rowData is still empty.
+ expect(Object.keys(traceWrapper.vm.rowData)).toHaveLength(0);
+ expect(traceWrapper.find(BUTTON).exists()).toBe(true);
+
+ await flushPromises();
+
+ // rowData arrives later; the action was already there and stays there.
+ expect(Object.keys(traceWrapper.vm.rowData).length).toBeGreaterThan(0);
+ expect(traceWrapper.find(BUTTON).exists()).toBe(true);
+ });
+
+ it("re-evaluates the gate when modelValue changes to a record without a trace id", async () => {
+ traceWrapper = mountDetailTable();
+ await flushPromises();
+ expect(traceWrapper.find(BUTTON).exists()).toBe(true);
+
+ await traceWrapper.setProps({ modelValue: defaultProps.modelValue });
+ await flushPromises();
+
+ expect(traceWrapper.find(BUTTON).exists()).toBe(false);
+ });
+
+ it("keeps the action on every tab, unlike the tab-scoped wrap toggle", async () => {
+ traceWrapper = mountDetailTable();
+ await flushPromises();
+
+ expect(traceWrapper.vm.tab).toBe("json");
+ expect(traceWrapper.find(BUTTON).isVisible()).toBe(true);
+ // Contrast: the wrap toggle is `v-show="tab === 'table'"`, so it is rendered
+ // but hidden on the JSON tab.
+ expect(traceWrapper.find(WRAP_TOGGLE).isVisible()).toBe(false);
+
+ traceWrapper.vm.tab = "table";
+ await nextTick();
+
+ expect(traceWrapper.find(BUTTON).isVisible()).toBe(true);
+ expect(traceWrapper.find(WRAP_TOGGLE).isVisible()).toBe(true);
+ });
+ });
});
diff --git a/web/src/plugins/logs/DetailTable.vue b/web/src/plugins/logs/DetailTable.vue
index 76a9465eb8..3d9331eddf 100644
--- a/web/src/plugins/logs/DetailTable.vue
+++ b/web/src/plugins/logs/DetailTable.vue
@@ -36,6 +36,31 @@ along with this program. If not, see .
data-test="logs-detail-ai-context-btn"
@sendToAiChat="sendToAiChat(JSON.stringify(rowData))"
/>
+
+
+
+ {{ t("search.viewTrace") }}
+
.
show-copy-button
mode="sidebar"
hide-view-related
+ hide-view-trace
:highlight-query="highlightQuery"
:should-wrap-values="shouldWrapValues"
@copy="copyContentToClipboard"
@@ -456,6 +482,7 @@ import ChunkedContent from "@/components/logs/ChunkedContent.vue";
import { extractStatusFromLog } from "@/utils/logs/statusParser";
import { logsUtils } from "@/composables/useLogs/logsUtils";
import { searchState } from "@/composables/useLogs/searchState";
+import useViewTraceAction from "@/composables/useLogs/useViewTraceAction";
import OButton from "@/lib/core/Button/OButton.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
@@ -600,6 +627,25 @@ export default defineComponent({
]);
const shouldWrapValues: any = ref(true);
const { searchObj } = searchState();
+
+ // The View Trace action is rendered in this component's header row (not
+ // inside JsonPreview) so it stays reachable from every tab. Gate on
+ // `modelValue`, which is a prop and therefore available during setup —
+ // `rowData` is only filled by the async created() hook below.
+ const {
+ tracesStreams,
+ isTracesStreamsLoading,
+ showViewTraceBtn,
+ setViewTraceBtn: setViewTraceBtnFor,
+ } = useViewTraceAction(t, searchObj);
+
+ watch(
+ () => props.modelValue,
+ (record) => {
+ setViewTraceBtnFor(record);
+ },
+ { immediate: true },
+ );
const { fnParsedSQL, hasAggregation } = logsUtils();
// Watch for initialTab prop changes to update tab
@@ -1040,6 +1086,9 @@ export default defineComponent({
searchObj,
multiStreamFields,
viewTrace,
+ tracesStreams,
+ isTracesStreamsLoading,
+ showViewTraceBtn,
hasAggregationQuery,
sendToAiChat,
addSearchTerm,
diff --git a/web/src/plugins/logs/JsonPreview.spec.ts b/web/src/plugins/logs/JsonPreview.spec.ts
index 6775a16dab..f842e5c949 100644
--- a/web/src/plugins/logs/JsonPreview.spec.ts
+++ b/web/src/plugins/logs/JsonPreview.spec.ts
@@ -1,4 +1,4 @@
-import { mount } from "@vue/test-utils";
+import { flushPromises, mount } from "@vue/test-utils";
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import JsonPreview from "@/plugins/logs/JsonPreview.vue";
import i18n from "@/locales";
@@ -1217,4 +1217,203 @@ describe("JsonPreview Component", () => {
expect(result).toEqual([]);
});
});
+
+ // The View Trace action is shared with DetailTable's row-detail drawer, which
+ // renders its own copy in the drawer header (hence `hideViewTrace`) and fills
+ // the previewed record in an async created() hook, so the preview mounts with
+ // an empty `value` and only receives the record later (hence the watcher).
+ describe("View Trace action visibility", () => {
+ const streamSelectSelector = '[data-test="log-search-index-list-select-stream"]';
+ const viewTraceBtnSelector = '[data-test="trace-view-logs-btn"]';
+ const recordWithTraceId = { ...mockValue, trace_id: "test-trace-id" };
+
+ let traceWrapper: any = null;
+
+ const mountPreview = (props: Record = {}) =>
+ mount(JsonPreview, {
+ props: { value: recordWithTraceId, mode: "sidebar", ...props },
+ global: {
+ plugins: [i18n],
+ provide: { store },
+ stubs: {
+ AppTabs: true,
+ CodeQueryEditor: true,
+ ODialog: ODialogStub,
+ OButton: true,
+ ODropdown: true,
+ ODropdownItem: true,
+ ODropdownSeparator: true,
+ OSelect: true,
+ OIcon: true,
+ OInput: true,
+ OSpinner: true,
+ OTooltip: true,
+ LogsHighLighting: true,
+ ChunkedContent: true,
+ EqualIcon: true,
+ NotEqualIcon: true,
+ },
+ },
+ });
+
+ beforeEach(() => {
+ // Everything the gate needs: traces menu visible, service streams off,
+ // and traces streams available for the picker.
+ store.state.hiddenMenus = new Set();
+ store.state.zoConfig.service_streams_enabled = false;
+ mockGetStreams.mockResolvedValue({
+ list: [{ name: "trace-stream1" }, { name: "trace-stream2" }],
+ });
+ });
+
+ afterEach(() => {
+ traceWrapper?.unmount();
+ traceWrapper = null;
+ });
+
+ describe("hideViewTrace prop", () => {
+ it("should default hideViewTrace to false", () => {
+ traceWrapper = mountPreview();
+
+ expect(traceWrapper.props("hideViewTrace")).toBe(false);
+ });
+
+ it("should render the stream picker and the button when the gate passes", async () => {
+ traceWrapper = mountPreview();
+ await flushPromises();
+
+ expect(traceWrapper.vm.showViewTraceBtn).toBeTruthy();
+ expect(traceWrapper.vm.tracesStreams).toEqual(["trace-stream1", "trace-stream2"]);
+ expect(traceWrapper.find(streamSelectSelector).exists()).toBe(true);
+ expect(traceWrapper.find(viewTraceBtnSelector).exists()).toBe(true);
+ });
+
+ it("should hide the stream picker and the button when hideViewTrace is true", async () => {
+ traceWrapper = mountPreview({ hideViewTrace: true });
+ await flushPromises();
+
+ // The gate itself still passes — only the prop hides the action.
+ expect(traceWrapper.vm.showViewTraceBtn).toBeTruthy();
+ expect(traceWrapper.vm.tracesStreams.length).toBeGreaterThan(0);
+ expect(traceWrapper.find(streamSelectSelector).exists()).toBe(false);
+ expect(traceWrapper.find(viewTraceBtnSelector).exists()).toBe(false);
+ });
+
+ it("should stay hidden with hideViewTrace even when value arrives late", async () => {
+ traceWrapper = mountPreview({ value: {}, hideViewTrace: true });
+ await flushPromises();
+
+ await traceWrapper.setProps({ value: recordWithTraceId });
+ await flushPromises();
+
+ expect(traceWrapper.vm.showViewTraceBtn).toBeTruthy();
+ expect(traceWrapper.find(streamSelectSelector).exists()).toBe(false);
+ expect(traceWrapper.find(viewTraceBtnSelector).exists()).toBe(false);
+ });
+ });
+
+ describe("value watcher", () => {
+ it("should hide the action while value is still empty", async () => {
+ traceWrapper = mountPreview({ value: {} });
+ await flushPromises();
+
+ expect(traceWrapper.vm.showViewTraceBtn).toBeFalsy();
+ expect(mockGetStreams).not.toHaveBeenCalled();
+ expect(traceWrapper.find(streamSelectSelector).exists()).toBe(false);
+ expect(traceWrapper.find(viewTraceBtnSelector).exists()).toBe(false);
+ });
+
+ it("should show the action once value is populated after mount", async () => {
+ traceWrapper = mountPreview({ value: {} });
+ await flushPromises();
+ expect(traceWrapper.find(viewTraceBtnSelector).exists()).toBe(false);
+
+ await traceWrapper.setProps({ value: recordWithTraceId });
+ await flushPromises();
+
+ expect(traceWrapper.vm.showViewTraceBtn).toBeTruthy();
+ expect(mockGetStreams).toHaveBeenCalledWith("traces", false);
+ expect(traceWrapper.vm.tracesStreams).toEqual(["trace-stream1", "trace-stream2"]);
+ expect(traceWrapper.find(streamSelectSelector).exists()).toBe(true);
+ expect(traceWrapper.find(viewTraceBtnSelector).exists()).toBe(true);
+ });
+
+ it("should render the action while the traces streams are still loading", async () => {
+ let resolveStreams: (value: any) => void = () => {};
+ mockGetStreams.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveStreams = resolve;
+ }),
+ );
+
+ traceWrapper = mountPreview({ value: {} });
+ await traceWrapper.setProps({ value: recordWithTraceId });
+ await nextTick();
+
+ // No streams have arrived yet: the loading flag alone keeps it rendered.
+ expect(traceWrapper.vm.tracesStreams).toEqual([]);
+ expect(traceWrapper.vm.isTracesStreamsLoading).toBe(true);
+ expect(traceWrapper.find(streamSelectSelector).exists()).toBe(true);
+ expect(traceWrapper.find(viewTraceBtnSelector).exists()).toBe(true);
+
+ resolveStreams({ list: [{ name: "trace-stream1" }] });
+ await flushPromises();
+
+ expect(traceWrapper.vm.isTracesStreamsLoading).toBe(false);
+ expect(traceWrapper.find(viewTraceBtnSelector).exists()).toBe(true);
+ });
+
+ it("should hide the action again when value is emptied", async () => {
+ traceWrapper = mountPreview();
+ await flushPromises();
+ expect(traceWrapper.find(viewTraceBtnSelector).exists()).toBe(true);
+
+ await traceWrapper.setProps({ value: {} });
+ await flushPromises();
+
+ expect(traceWrapper.vm.showViewTraceBtn).toBeFalsy();
+ expect(traceWrapper.find(streamSelectSelector).exists()).toBe(false);
+ expect(traceWrapper.find(viewTraceBtnSelector).exists()).toBe(false);
+ });
+
+ it("should hide the action when the new value carries no trace id", async () => {
+ traceWrapper = mountPreview();
+ await flushPromises();
+
+ await traceWrapper.setProps({ value: { ...mockValue } });
+ await flushPromises();
+
+ expect(traceWrapper.vm.showViewTraceBtn).toBeFalsy();
+ expect(traceWrapper.find(viewTraceBtnSelector).exists()).toBe(false);
+ });
+
+ it("should not refetch the traces streams on every value change", async () => {
+ traceWrapper = mountPreview({ value: {} });
+ await traceWrapper.setProps({ value: recordWithTraceId });
+ await flushPromises();
+ expect(mockGetStreams).toHaveBeenCalledTimes(1);
+
+ await traceWrapper.setProps({
+ value: { ...recordWithTraceId, trace_id: "another-trace-id" },
+ });
+ await flushPromises();
+
+ expect(mockGetStreams).toHaveBeenCalledTimes(1);
+ expect(traceWrapper.find(viewTraceBtnSelector).exists()).toBe(true);
+ });
+
+ it("should keep the action hidden when the traces menu is hidden", async () => {
+ store.state.hiddenMenus = new Set(["traces"]);
+ traceWrapper = mountPreview({ value: {} });
+
+ await traceWrapper.setProps({ value: recordWithTraceId });
+ await flushPromises();
+
+ expect(traceWrapper.vm.showViewTraceBtn).toBe(false);
+ expect(mockGetStreams).not.toHaveBeenCalled();
+ expect(traceWrapper.find(viewTraceBtnSelector).exists()).toBe(false);
+ });
+ });
+ });
});
diff --git a/web/src/plugins/logs/JsonPreview.vue b/web/src/plugins/logs/JsonPreview.vue
index cb1cc63b2e..d115d843d3 100644
--- a/web/src/plugins/logs/JsonPreview.vue
+++ b/web/src/plugins/logs/JsonPreview.vue
@@ -34,7 +34,9 @@
("");
- const { getStreams } = useStreams(t);
-
- const filteredTracesStreamOptions = ref([]);
-
const router = useRouter();
- const tracesStreams = ref([]);
-
const queryEditorRef = ref();
- const isTracesStreamsLoading = ref(false);
-
const typeOfRegexPattern = ref(false);
const regexPatternType = ref("");
@@ -414,6 +414,20 @@ export default {
};
const { searchObj, searchAggData } = searchState();
+ // Shared with DetailTable's drawer header so both renderings of the
+ // View Trace action apply the same rules. See useViewTraceAction.
+ const {
+ tracesStreams,
+ filteredTracesStreamOptions,
+ isTracesStreamsLoading,
+ showViewTraceBtn,
+ getTracesStreams,
+ setViewTraceBtn: setViewTraceBtnFor,
+ filterStreamFn,
+ } = useViewTraceAction(t, searchObj);
+
+ const setViewTraceBtn = () => setViewTraceBtnFor(props.value);
+
// Cross-linking: get all matching cross-links for a field using result_schema data
const getCrossLinksForField = (
fieldName: string,
@@ -498,7 +512,6 @@ export default {
};
let multiStreamFields: any = ref([]);
- const showViewTraceBtn = ref(false);
const showViewRelatedBtn = ref(false);
// Initialize service correlation composable
@@ -532,38 +545,18 @@ export default {
}
});
- const getTracesStreams = async () => {
- isTracesStreamsLoading.value = true;
- try {
- getStreams("traces", false)
- .then((res: any) => {
- tracesStreams.value = res.list.map((option: any) => option.name);
- filteredTracesStreamOptions.value = JSON.parse(JSON.stringify(tracesStreams.value));
-
- if (!searchObj.meta.selectedTraceStream.length)
- searchObj.meta.selectedTraceStream = tracesStreams.value[0];
- })
- .catch(() => Promise.reject())
- .finally(() => {
- isTracesStreamsLoading.value = false;
- });
- } catch (err: any) {
- isTracesStreamsLoading.value = false;
- console.error("Failed to get traces streams", err);
- }
- };
-
- const setViewTraceBtn = () => {
- // Hide view traces button when service_streams_enabled is true
- const serviceStreamsEnabled = store.state.zoConfig.service_streams_enabled !== false;
-
- showViewTraceBtn.value =
- !store.state.hiddenMenus.has("traces") && // Check if traces menu is hidden
- !serviceStreamsEnabled && // Hide when service streams is enabled
- props.value[store.state.organizationData?.organizationSettings?.trace_id_field_name];
-
- if (showViewTraceBtn.value && !filteredTracesStreamOptions.value.length) getTracesStreams();
- };
+ // `value` can arrive after this component mounts: DetailTable fills its
+ // `rowData` in an async created() hook, so the row-detail drawer's preview
+ // mounts with `{}`. The onBeforeMount check below then ran against an empty
+ // record, left `showViewTraceBtn` false and never fetched the traces
+ // streams, so the drawer's View Trace button stayed hidden for good.
+ // Re-evaluate whenever the previewed record changes.
+ watch(
+ () => props.value,
+ () => {
+ setViewTraceBtn();
+ },
+ );
onBeforeMount(() => {
setViewTraceBtn();
@@ -704,12 +697,6 @@ export default {
},
);
- const filterStreamFn = (val: any = "") => {
- filteredTracesStreamOptions.value = tracesStreams.value.filter((stream: any) => {
- return stream.toLowerCase().indexOf(val.toLowerCase()) > -1;
- });
- };
-
// The previewed log rides along with the event: a listener bound by
// reference (`@view-trace="handler"`) otherwise receives `undefined` and
// throws on `log[timestamp_column]` (issue #13708). Same contract as
diff --git a/web/src/plugins/traces/TraceDetails.vue b/web/src/plugins/traces/TraceDetails.vue
index 11cf5f8302..977fe2e6d0 100644
--- a/web/src/plugins/traces/TraceDetails.vue
+++ b/web/src/plugins/traces/TraceDetails.vue
@@ -797,15 +797,13 @@ along with this program. If not, see .
diff --git a/web/src/plugins/traces/TraceTree.spec.ts b/web/src/plugins/traces/TraceTree.spec.ts
index 10865d4801..923c01d0de 100644
--- a/web/src/plugins/traces/TraceTree.spec.ts
+++ b/web/src/plugins/traces/TraceTree.spec.ts
@@ -51,14 +51,32 @@ vi.mock("@/utils/traces/convertTraceData", () => ({
getSpanTechIconDataUrl: vi.fn().mockReturnValue(null),
}));
+// Shared, stable spies for the useTraces helpers so `viewSpanLogs` can be
+// asserted on. `searchObj` is a plain mutable object: tests flip
+// `data.traceDetails.selectedLogStreams` to drive the OSS stream guard.
+const { mockBuildQueryDetails, mockNavigateToLogs, mockToast, mockSearchObj } = vi.hoisted(() => ({
+ mockBuildQueryDetails: vi.fn(),
+ mockNavigateToLogs: vi.fn(),
+ mockToast: vi.fn(),
+ mockSearchObj: {
+ meta: { serviceColors: {} },
+ data: { traceDetails: { selectedLogStreams: [] as string[] } },
+ },
+}));
+
vi.mock("@/composables/useTraces", () => ({
default: () => ({
- searchObj: { meta: { serviceColors: {} } },
- buildQueryDetails: vi.fn(),
- navigateToLogs: vi.fn(),
+ searchObj: mockSearchObj,
+ buildQueryDetails: mockBuildQueryDetails,
+ navigateToLogs: mockNavigateToLogs,
}),
}));
+vi.mock("@/lib/feedback/Toast/useToast", async () => {
+ const actual = await vi.importActual>("@/lib/feedback/Toast/useToast");
+ return { ...actual, toast: mockToast };
+});
+
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import i18n from "@/locales";
@@ -66,6 +84,7 @@ import router from "@/test/unit/helpers/router";
import { createStore } from "vuex";
import TraceTree from "@/plugins/traces/TraceTree.vue";
+import config from "@/aws-exports";
const mockStore = createStore({
state: {
@@ -1366,4 +1385,222 @@ describe("TraceTree", () => {
expect(badge.attributes("title")).toContain("collapse");
});
});
+
+ describe("viewSpanLogs", () => {
+ // A formatted tree node — what the `spans` prop holds: camelCase keys and
+ // microsecond timestamps. This is NOT the shape buildQueryDetails accepts.
+ const formattedSpan = {
+ spanId: "d9603ec7f76eb499",
+ operationName: "service:alerts:evaluate_scheduled",
+ serviceName: "scheduler",
+ resolvedIdentity: "scheduler",
+ spanStatus: "UNSET",
+ spanKind: "Client",
+ parentId: null,
+ hasChildSpans: false,
+ startTimeUs: 1752490492843047,
+ endTimeUs: 1752490493164419,
+ style: {
+ color: "#1ab8be",
+ backgroundColor: "#1ab8be33",
+ top: "0px",
+ left: "0px",
+ },
+ depth: 0,
+ index: 0,
+ };
+
+ // The raw API span behind it — what `spanMap` holds: snake_case keys and
+ // nanosecond timestamps. buildQueryDetails must receive THIS object.
+ const rawSpan = {
+ _timestamp: 1752490492843047,
+ start_time: 1752490492843047200,
+ end_time: 1752490493164419300,
+ duration: 321372,
+ span_id: "d9603ec7f76eb499",
+ operation_name: "service:alerts:evaluate_scheduled",
+ service_name: "scheduler",
+ span_status: "UNSET",
+ span_kind: 2,
+ parent_id: null,
+ };
+
+ // Real case: a span with no `span_id` is given a synthetic `spanId` by the
+ // formatter, so `spanMap` has no entry for it.
+ const orphanSpan = {
+ ...formattedSpan,
+ spanId: "synthetic-span-id-0",
+ };
+
+ const queryDetailsStub = {
+ stream_name: "default",
+ from: 1752490492843,
+ to: 1752490493164,
+ };
+
+ const viewLogsBtnFor = (w: any, spanId: string) =>
+ w.find(`[data-test="trace-tree-span-view-logs-btn-${spanId}"]`);
+
+ const originalIsEnterprise = config.isEnterprise;
+
+ /** Mounts the tree with a single span and a spanMap containing only rawSpan. */
+ function mountWithSpan(span: Record) {
+ return mountTraceTree({
+ spans: [span],
+ spanMap: { [rawSpan.span_id]: rawSpan },
+ spanList: [rawSpan],
+ });
+ }
+
+ beforeEach(() => {
+ mockSearchObj.data.traceDetails.selectedLogStreams = [];
+ mockBuildQueryDetails.mockReturnValue(queryDetailsStub);
+ });
+
+ afterEach(() => {
+ config.isEnterprise = originalIsEnterprise;
+ mockSearchObj.data.traceDetails.selectedLogStreams = [];
+ });
+
+ describe("OSS mode (config.isEnterprise !== 'true')", () => {
+ let ossWrapper: any;
+
+ beforeEach(() => {
+ config.isEnterprise = "false";
+ });
+
+ afterEach(() => {
+ ossWrapper?.unmount();
+ ossWrapper = null;
+ });
+
+ it("should warn and skip both helpers when no logs stream is selected", async () => {
+ mockSearchObj.data.traceDetails.selectedLogStreams = [];
+ ossWrapper = mountWithSpan(formattedSpan);
+
+ await viewLogsBtnFor(ossWrapper, formattedSpan.spanId).trigger("click");
+
+ expect(mockToast).toHaveBeenCalledTimes(1);
+ expect(mockToast).toHaveBeenCalledWith({
+ variant: "warning",
+ message: "Select Logs stream first",
+ });
+ expect(mockBuildQueryDetails).not.toHaveBeenCalled();
+ expect(mockNavigateToLogs).not.toHaveBeenCalled();
+ });
+
+ it("should not emit view-correlated-logs when no logs stream is selected", async () => {
+ mockSearchObj.data.traceDetails.selectedLogStreams = [];
+ ossWrapper = mountWithSpan(formattedSpan);
+
+ await viewLogsBtnFor(ossWrapper, formattedSpan.spanId).trigger("click");
+
+ expect(ossWrapper.emitted("view-correlated-logs")).toBeFalsy();
+ });
+
+ it("should warn and skip both helpers when the span is missing from spanMap", async () => {
+ mockSearchObj.data.traceDetails.selectedLogStreams = ["default"];
+ ossWrapper = mountWithSpan(orphanSpan);
+
+ await viewLogsBtnFor(ossWrapper, orphanSpan.spanId).trigger("click");
+
+ expect(mockToast).toHaveBeenCalledTimes(1);
+ expect(mockToast).toHaveBeenCalledWith({
+ variant: "warning",
+ message: "Could not open logs for this span.",
+ });
+ expect(mockBuildQueryDetails).not.toHaveBeenCalled();
+ expect(mockNavigateToLogs).not.toHaveBeenCalled();
+ });
+
+ it("should pass the RAW span from spanMap to buildQueryDetails, not the formatted node", async () => {
+ mockSearchObj.data.traceDetails.selectedLogStreams = ["default"];
+ ossWrapper = mountWithSpan(formattedSpan);
+
+ await viewLogsBtnFor(ossWrapper, formattedSpan.spanId).trigger("click");
+
+ expect(mockBuildQueryDetails).toHaveBeenCalledTimes(1);
+
+ const passedSpan = mockBuildQueryDetails.mock.calls[0][0];
+ // Raw shape: snake_case keys, nanosecond timestamps.
+ expect(passedSpan).toEqual(rawSpan);
+ expect(passedSpan.span_id).toBe(rawSpan.span_id);
+ expect(passedSpan.start_time).toBe(rawSpan.start_time);
+ expect(passedSpan.end_time).toBe(rawSpan.end_time);
+ // Formatted shape must be absent — passing it produced `from=NaN&to=NaN`.
+ expect(passedSpan.spanId).toBeUndefined();
+ expect(passedSpan.startTimeUs).toBeUndefined();
+ expect(passedSpan.endTimeUs).toBeUndefined();
+ expect(mockBuildQueryDetails).not.toHaveBeenCalledWith(
+ expect.objectContaining({ spanId: formattedSpan.spanId }),
+ );
+ });
+
+ it("should navigate with the query details returned by buildQueryDetails", async () => {
+ mockSearchObj.data.traceDetails.selectedLogStreams = ["default"];
+ ossWrapper = mountWithSpan(formattedSpan);
+
+ await viewLogsBtnFor(ossWrapper, formattedSpan.spanId).trigger("click");
+
+ expect(mockNavigateToLogs).toHaveBeenCalledTimes(1);
+ expect(mockNavigateToLogs).toHaveBeenCalledWith(queryDetailsStub);
+ expect(mockNavigateToLogs.mock.calls[0][0]).toBe(queryDetailsStub);
+ expect(mockToast).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("Enterprise mode (config.isEnterprise === 'true')", () => {
+ let entWrapper: any;
+
+ beforeEach(() => {
+ config.isEnterprise = "true";
+ });
+
+ afterEach(() => {
+ entWrapper?.unmount();
+ entWrapper = null;
+ });
+
+ it("should emit view-correlated-logs with the original formatted span", async () => {
+ mockSearchObj.data.traceDetails.selectedLogStreams = ["default"];
+ entWrapper = mountWithSpan(formattedSpan);
+
+ await viewLogsBtnFor(entWrapper, formattedSpan.spanId).trigger("click");
+
+ expect(entWrapper.emitted("view-correlated-logs")).toBeTruthy();
+ expect(entWrapper.emitted("view-correlated-logs")).toHaveLength(1);
+ expect(entWrapper.emitted("view-correlated-logs")[0][0]).toEqual(formattedSpan);
+ });
+
+ it("should never call buildQueryDetails or navigateToLogs", async () => {
+ mockSearchObj.data.traceDetails.selectedLogStreams = ["default"];
+ entWrapper = mountWithSpan(formattedSpan);
+
+ await viewLogsBtnFor(entWrapper, formattedSpan.spanId).trigger("click");
+
+ expect(mockBuildQueryDetails).not.toHaveBeenCalled();
+ expect(mockNavigateToLogs).not.toHaveBeenCalled();
+ });
+
+ it("should bypass the stream guard when no logs stream is selected", async () => {
+ mockSearchObj.data.traceDetails.selectedLogStreams = [];
+ entWrapper = mountWithSpan(formattedSpan);
+
+ await viewLogsBtnFor(entWrapper, formattedSpan.spanId).trigger("click");
+
+ expect(mockToast).not.toHaveBeenCalled();
+ expect(entWrapper.emitted("view-correlated-logs")).toHaveLength(1);
+ });
+
+ it("should bypass the spanMap guard for a span missing from spanMap", async () => {
+ mockSearchObj.data.traceDetails.selectedLogStreams = ["default"];
+ entWrapper = mountWithSpan(orphanSpan);
+
+ await viewLogsBtnFor(entWrapper, orphanSpan.spanId).trigger("click");
+
+ expect(mockToast).not.toHaveBeenCalled();
+ expect(entWrapper.emitted("view-correlated-logs")[0][0]).toEqual(orphanSpan);
+ });
+ });
+ });
});
diff --git a/web/src/plugins/traces/TraceTree.vue b/web/src/plugins/traces/TraceTree.vue
index 4b958b5c62..6fa566b685 100644
--- a/web/src/plugins/traces/TraceTree.vue
+++ b/web/src/plugins/traces/TraceTree.vue
@@ -328,6 +328,7 @@ import useTheme from "@/composables/useTheme";
import SpanBlock from "./SpanBlock.vue";
import SpanKindBadge from "./components/SpanKindBadge.vue";
import { useI18nTyped } from "@/types/i18n";
+import { toast } from "@/lib/feedback/Toast/useToast";
import { formatTokens, formatCost, isLLMTrace } from "@/utils/llmUtils";
import { getServiceIconDataUrl, getSpanTechIconDataUrl } from "@/utils/traces/convertTraceData";
@@ -413,7 +414,7 @@ export default defineComponent({
"view-correlated-logs",
],
setup(props, { emit }) {
- const { buildQueryDetails, navigateToLogs } = useTraces();
+ const { buildQueryDetails, navigateToLogs, searchObj } = useTraces();
const store = useStore();
const { isDark } = useTheme();
@@ -524,7 +525,33 @@ export default defineComponent({
emit("view-correlated-logs", span);
return;
}
- const queryDetails = buildQueryDetails(span);
+ // OSS resolves the target stream from the header's picker. Without a
+ // selection we would navigate to `/logs?stream=`, which drops the filter
+ // and dumps an unfiltered result set on the user. The header's own View
+ // Logs button is disabled in this state; this row-level one is not, so
+ // say why instead of leaving it a silent dead end (same handling as #13722).
+ if (!searchObj.data.traceDetails.selectedLogStreams.length) {
+ toast({
+ variant: "warning",
+ message: t("search.selectLogsStreamFirst"),
+ });
+ return;
+ }
+ // `spans` are formatted nodes (camelCase, microseconds); `buildQueryDetails`
+ // reads the raw API shape (`start_time`/`end_time`, nanoseconds). Resolve the
+ // raw span the same way the rest of this component does rather than making the
+ // shared helper tolerate two shapes — passing a formatted node produced
+ // `from=NaN&to=NaN`. A span missing `span_id` gets a synthetic `spanId` that is
+ // absent from `spanMap`, so the lookup can miss.
+ const rawSpan = props.spanMap[span.spanId];
+ if (!rawSpan) {
+ toast({
+ variant: "warning",
+ message: t("traces.viewLogsUnavailable"),
+ });
+ return;
+ }
+ const queryDetails = buildQueryDetails(rawSpan);
navigateToLogs(queryDetails);
};