fix(traces): trace-log correlation fixes and Trace Graph height (#13901)

## Problem

Four defects around traces and the trace↔log correlation flows:

1. **Trace Graph is clipped.** The service map renders inside a 200px
band, vertically centred in an otherwise empty pane, instead of using
the available height.
2. **A span row's View Logs goes nowhere.** In OSS builds it navigates
to `/logs?stream=&from=NaN&to=NaN`; the logs page drops the filter and
falls back to its default window, so the user gets an unfiltered result
set instead of that span's logs.
3. **The log detail drawer has no View Trace button.** Opening the trace
for a log record only works from the inline expanded row.
4. **View Trace lived inside the JSON tab**, so it disappeared when
switching to Table.

## Root causes

**Trace Graph.** The ancestor chain was already correct — the sibling
Waterfall tab fills the same box. Only the leaf was clamped: the chart
carried `h-50! min-h-50!` (12.5rem, `!important`), which beat its own
root `h-full w-full`. That clamp dates to when the map lived in a small
collapsible timeline strip, where 200px was the intended design. When it
was promoted to a full-height tab, the scoped rule `.trace-details
.trace-chart-height { height: 12.5rem }` was left behind and its
two-class specificity silently defeated the `h-full!` added at the time.
The later design-token migration deleted the rule but preserved the
rendered result by translating it into utilities, which made the clamp
look deliberate.

**Span View Logs.** `buildQueryDetails()` reads the raw API shape
(`start_time`/`end_time`, nanoseconds), but the waterfall passes
formatted nodes, which `getFormattedSpan()` rebuilds as a fresh object
literal — camelCase, times as `startTimeUs`/`endTimeUs` in microseconds,
snake_case originals dropped. Reading `start_time` off one yields
`undefined`, hence `NaN`. The helper already had a `spanId || span_id`
fallback for the id, which is why the query string was correct while the
window was not.

**Drawer View Trace.** `JsonPreview` decides whether to offer the action
once, in `onBeforeMount`. `DetailTable` fills `rowData` in an `async
created()` — the `await` defers assignment past the child's mount even
though the work is synchronous — so the preview mounted with an empty
record, the check failed, the traces streams were never fetched, and
nothing recomputed it.

## Changes

- Trace Graph chart uses `h-full`; the orphaned `trace-chart-height`
class is dropped (its rule no longer exists) and two nested wrappers
collapse into one. Matches the full-bleed pattern in `ServiceGraph.vue`.
- The span row resolves the raw span from `spanMap` before calling the
helper — the same lookup this component already does for span data, HTTP
status and events — rather than teaching the shared helper to accept two
shapes. Guarded, since a span missing `span_id` gets a synthetic id
absent from the map.
- The span row also warns instead of navigating when no logs stream is
selected. The header's own View Logs button is disabled in that state
but the row-level one is not, so it was a silent dead end.
- `JsonPreview` re-evaluates the View Trace gate when the previewed
record changes, so the flag tracks its input rather than a single
snapshot.
- The action moves to the drawer's header row beside the wrap toggle,
reachable from every tab, with `hide-view-trace` on `JsonPreview`
(mirroring the existing `hide-view-related`). The gate, stream fetch and
filter move into `useViewTraceAction` rather than being copied into
`DetailTable` — `service_streams_enabled` is already hand-rolled in
three files and drifted. `JsonPreview` keeps exposing the same names, so
its specs are untouched.

Two latent issues surfaced while moving the action:

- `hiddenMenus` is seeded as an array and only becomes a `Set` once
`MainLayout` has read `custom_hide_menus`, so `.has()` is not always
available. Both shapes are now accepted.
- `OSelect`'s root sets `w-full`, which won a plain `w-40` and pushed
the button outside the drawer — the same class-collision shape as defect
1. Pinned with `w-40!`.

## Tests

No behaviour is asserted by new specs; existing suites cover the touched
paths and pass unmodified:

- `JsonPreview.spec.ts` — 91 passed (unchanged, including the assertions
on `tracesStreams`)
- `DetailTable.spec.ts` — 70 passed, 3 skipped
- `TraceTree.spec.ts`, `useTraces.spec.ts`,
`TraceDetailsSidebar.spec.ts` — 297 passed
- `TraceDetails` suites — 234 passed
- `SearchResult.viewTrace.spec.ts`,
`CorrelatedLogsTable.viewTrace.spec.ts` — passed

Prettier and ESLint are clean on every touched file.

## Verification

Checked against live trace and log data sharing a trace id:

- Trace Graph fills its pane (measured 549px where it previously
measured 200px), survives a tab round-trip, and reflows on window
resize.
- A span row's View Logs lands on `/logs` with the span+trace filter and
a ±60s window whose bounds match the span's real timestamps exactly;
with no stream selected it warns and does not navigate.
- The drawer's header shows the picker and button on both the JSON and
Table tabs with no overflow, and opens the trace with the correct id and
a ±15 min window.
- Console clean across every navigation.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Omkar Kesarkhane
2026-08-21 05:58:02 +00:00
committed by GitHub
co-authored by github-actions[bot]
parent e28de6c6c3
commit fac24d59bb
25 changed files with 1474 additions and 137 deletions
@@ -14312,6 +14312,7 @@
"unknownService": "30dbef66f0d96ed5",
"viewFilters": "6fae22556dc2d946",
"viewLogs": "e087f046dc3cda9a",
"viewLogsUnavailable": "e2468fc4f70cc910",
"waterfall": "6b53bc225e0aae1d"
},
"user": {
@@ -0,0 +1,521 @@
// 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 { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { flushPromises } from "@vue/test-utils";
import { gt } from "@/types/i18n";
const { mockGetStreams } = vi.hoisted(() => ({
mockGetStreams: vi.fn(),
}));
vi.mock("@/composables/useStreams", () => ({
default: () => ({
getStreams: mockGetStreams,
}),
}));
// The composable resolves the store itself, so the whole vuex module is mocked
// and `mockStore.state` is re-seeded per test rather than mutated across tests.
const mockStore: any = { state: {} };
vi.mock("vuex", () => ({
useStore: () => mockStore,
}));
import useViewTraceAction from "./useViewTraceAction";
const TRACE_ID_FIELD = "trace_id";
/** A log record that carries the configured trace id field. */
const recordWithTraceId = { [TRACE_ID_FIELD]: "abc-123", message: "hello" };
/** A log record from a stream that never emits a trace id. */
const recordWithoutTraceId = { message: "hello" };
/** Fresh search state per test — the real one is shared module state. */
const makeSearchObj = (selectedTraceStream = "") => ({
meta: { selectedTraceStream },
});
describe("useViewTraceAction", () => {
let searchObj: ReturnType<typeof makeSearchObj>;
beforeEach(() => {
vi.clearAllMocks();
mockStore.state = {
// `false` is the only value that enables the action — see the gate tests.
zoConfig: { service_streams_enabled: false },
hiddenMenus: [],
organizationData: {
organizationSettings: { trace_id_field_name: TRACE_ID_FIELD },
},
};
mockGetStreams.mockResolvedValue({
list: [{ name: "trace-stream1" }, { name: "trace-stream2" }],
});
searchObj = makeSearchObj();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("initial state", () => {
it("starts with no streams, nothing loading and the button hidden", () => {
const action = useViewTraceAction(gt, searchObj);
expect(action.tracesStreams.value).toEqual([]);
expect(action.filteredTracesStreamOptions.value).toEqual([]);
expect(action.isTracesStreamsLoading.value).toBe(false);
expect(action.showViewTraceBtn.value).toBe(false);
});
it("does not fetch streams until the action is evaluated", () => {
useViewTraceAction(gt, searchObj);
expect(mockGetStreams).not.toHaveBeenCalled();
});
it("gives each caller its own refs so JsonPreview and DetailTable do not share state", () => {
const first = useViewTraceAction(gt, searchObj);
const second = useViewTraceAction(gt, searchObj);
first.tracesStreams.value = ["only-mine"];
expect(second.tracesStreams.value).toEqual([]);
});
});
describe("setViewTraceBtn — gate", () => {
it("shows the button when the menu is visible, service streams are off and the record has a trace id", async () => {
const { setViewTraceBtn, showViewTraceBtn } = useViewTraceAction(gt, searchObj);
setViewTraceBtn(recordWithTraceId);
await flushPromises();
expect(showViewTraceBtn.value).toBeTruthy();
});
it("hides the button when the traces menu is hidden", async () => {
mockStore.state.hiddenMenus = new Set(["traces"]);
const { setViewTraceBtn, showViewTraceBtn } = useViewTraceAction(gt, searchObj);
setViewTraceBtn(recordWithTraceId);
await flushPromises();
expect(showViewTraceBtn.value).toBeFalsy();
});
it("hides the button when service streams are enabled", async () => {
mockStore.state.zoConfig.service_streams_enabled = true;
const { setViewTraceBtn, showViewTraceBtn } = useViewTraceAction(gt, searchObj);
setViewTraceBtn(recordWithTraceId);
await flushPromises();
expect(showViewTraceBtn.value).toBeFalsy();
});
it("treats service streams as enabled when the flag is missing entirely", async () => {
mockStore.state.zoConfig = {};
const { setViewTraceBtn, showViewTraceBtn } = useViewTraceAction(gt, searchObj);
setViewTraceBtn(recordWithTraceId);
await flushPromises();
expect(showViewTraceBtn.value).toBeFalsy();
});
it("hides the button when the record has no trace id field", async () => {
const { setViewTraceBtn, showViewTraceBtn } = useViewTraceAction(gt, searchObj);
setViewTraceBtn(recordWithoutTraceId);
await flushPromises();
expect(showViewTraceBtn.value).toBeFalsy();
});
it("hides the button when the record's trace id is an empty string", async () => {
const { setViewTraceBtn, showViewTraceBtn } = useViewTraceAction(gt, searchObj);
setViewTraceBtn({ [TRACE_ID_FIELD]: "" });
await flushPromises();
expect(showViewTraceBtn.value).toBeFalsy();
});
it("hides the button when the org has no trace id field configured", async () => {
mockStore.state.organizationData.organizationSettings = {};
const { setViewTraceBtn, showViewTraceBtn } = useViewTraceAction(gt, searchObj);
setViewTraceBtn(recordWithTraceId);
await flushPromises();
expect(showViewTraceBtn.value).toBeFalsy();
});
it("re-evaluates on every call, so the button can go from shown to hidden", async () => {
const { setViewTraceBtn, showViewTraceBtn } = useViewTraceAction(gt, searchObj);
setViewTraceBtn(recordWithTraceId);
await flushPromises();
expect(showViewTraceBtn.value).toBeTruthy();
setViewTraceBtn(recordWithoutTraceId);
await flushPromises();
expect(showViewTraceBtn.value).toBeFalsy();
});
});
describe("setViewTraceBtn — missing record (row-detail drawer regression)", () => {
it.each([
["null", null],
["undefined", undefined],
["an empty object", {}],
])("does not throw and hides the button when the record is %s", async (_label, record) => {
const { setViewTraceBtn, showViewTraceBtn } = useViewTraceAction(gt, searchObj);
expect(() => setViewTraceBtn(record)).not.toThrow();
await flushPromises();
expect(showViewTraceBtn.value).toBeFalsy();
expect(mockGetStreams).not.toHaveBeenCalled();
});
it("does not throw when the org settings are not loaded yet", async () => {
mockStore.state.organizationData = {};
const { setViewTraceBtn, showViewTraceBtn } = useViewTraceAction(gt, searchObj);
expect(() => setViewTraceBtn(recordWithTraceId)).not.toThrow();
await flushPromises();
expect(showViewTraceBtn.value).toBeFalsy();
});
});
describe("setViewTraceBtn — hiddenMenus shape", () => {
// `hiddenMenus` is seeded as an array by the store and only replaced with a
// Set by MainLayout, so both shapes reach this code. Calling `.has` on the
// array shape used to crash the drawer.
it("hides the button when hiddenMenus is a Set containing 'traces'", async () => {
mockStore.state.hiddenMenus = new Set(["traces"]);
const { setViewTraceBtn, showViewTraceBtn } = useViewTraceAction(gt, searchObj);
setViewTraceBtn(recordWithTraceId);
await flushPromises();
expect(showViewTraceBtn.value).toBeFalsy();
});
it("shows the button when hiddenMenus is a Set without 'traces'", async () => {
mockStore.state.hiddenMenus = new Set(["metrics"]);
const { setViewTraceBtn, showViewTraceBtn } = useViewTraceAction(gt, searchObj);
setViewTraceBtn(recordWithTraceId);
await flushPromises();
expect(showViewTraceBtn.value).toBeTruthy();
});
it("hides the button when hiddenMenus is an Array containing 'traces'", async () => {
mockStore.state.hiddenMenus = ["traces"];
const { setViewTraceBtn, showViewTraceBtn } = useViewTraceAction(gt, searchObj);
expect(() => setViewTraceBtn(recordWithTraceId)).not.toThrow();
await flushPromises();
expect(showViewTraceBtn.value).toBeFalsy();
});
it("shows the button when hiddenMenus is an Array without 'traces'", async () => {
mockStore.state.hiddenMenus = ["metrics"];
const { setViewTraceBtn, showViewTraceBtn } = useViewTraceAction(gt, searchObj);
expect(() => setViewTraceBtn(recordWithTraceId)).not.toThrow();
await flushPromises();
expect(showViewTraceBtn.value).toBeTruthy();
});
it("shows the button when hiddenMenus is undefined", async () => {
mockStore.state.hiddenMenus = undefined;
const { setViewTraceBtn, showViewTraceBtn } = useViewTraceAction(gt, searchObj);
expect(() => setViewTraceBtn(recordWithTraceId)).not.toThrow();
await flushPromises();
expect(showViewTraceBtn.value).toBeTruthy();
});
});
describe("setViewTraceBtn — lazy stream loading", () => {
it("fetches the traces streams the first time the button is shown", async () => {
const { setViewTraceBtn, filteredTracesStreamOptions } = useViewTraceAction(gt, searchObj);
setViewTraceBtn(recordWithTraceId);
await flushPromises();
expect(mockGetStreams).toHaveBeenCalledTimes(1);
expect(mockGetStreams).toHaveBeenCalledWith("traces", false);
expect(filteredTracesStreamOptions.value).toEqual(["trace-stream1", "trace-stream2"]);
});
it("does not refetch once the options are populated", async () => {
const { setViewTraceBtn } = useViewTraceAction(gt, searchObj);
setViewTraceBtn(recordWithTraceId);
await flushPromises();
expect(mockGetStreams).toHaveBeenCalledTimes(1);
setViewTraceBtn(recordWithTraceId);
await flushPromises();
expect(mockGetStreams).toHaveBeenCalledTimes(1);
});
it("does not fetch when the gate fails, even with empty options", async () => {
mockStore.state.hiddenMenus = new Set(["traces"]);
const { setViewTraceBtn } = useViewTraceAction(gt, searchObj);
setViewTraceBtn(recordWithTraceId);
await flushPromises();
expect(mockGetStreams).not.toHaveBeenCalled();
});
it("does not refetch for an org whose stream list came back empty", async () => {
// The guard latches on a *completed* fetch rather than on
// `filteredTracesStreamOptions.length`. An org with no traces streams
// leaves that array empty forever, so the old check re-fetched on every
// record; this is the case that regressed.
mockGetStreams.mockResolvedValue({ list: [] });
const { setViewTraceBtn, filteredTracesStreamOptions } = useViewTraceAction(gt, searchObj);
setViewTraceBtn(recordWithTraceId);
await flushPromises();
setViewTraceBtn(recordWithTraceId);
await flushPromises();
expect(mockGetStreams).toHaveBeenCalledTimes(1);
expect(filteredTracesStreamOptions.value).toEqual([]);
});
});
describe("getTracesStreams", () => {
it("maps the response list to stream names", async () => {
const { getTracesStreams, tracesStreams } = useViewTraceAction(gt, searchObj);
await getTracesStreams();
await flushPromises();
expect(mockGetStreams).toHaveBeenCalledWith("traces", false);
expect(tracesStreams.value).toEqual(["trace-stream1", "trace-stream2"]);
});
it("copies the names into the filter options without sharing the array reference", async () => {
const { getTracesStreams, tracesStreams, filteredTracesStreamOptions } = useViewTraceAction(
gt,
searchObj,
);
await getTracesStreams();
await flushPromises();
expect(filteredTracesStreamOptions.value).toEqual(tracesStreams.value);
expect(filteredTracesStreamOptions.value).not.toBe(tracesStreams.value);
});
it("raises the loading flag while fetching and clears it once resolved", async () => {
const { getTracesStreams, isTracesStreamsLoading } = useViewTraceAction(gt, searchObj);
// The call awaits the fetch internally, so the flag is only observable
// as true before the returned promise settles.
const pending = getTracesStreams();
expect(isTracesStreamsLoading.value).toBe(true);
await pending;
expect(isTracesStreamsLoading.value).toBe(false);
});
it("selects the first stream when nothing is selected yet", async () => {
const { getTracesStreams } = useViewTraceAction(gt, searchObj);
await getTracesStreams();
await flushPromises();
expect(searchObj.meta.selectedTraceStream).toBe("trace-stream1");
});
it("keeps an existing selection instead of overwriting it", async () => {
searchObj = makeSearchObj("already-chosen-stream");
const { getTracesStreams } = useViewTraceAction(gt, searchObj);
await getTracesStreams();
await flushPromises();
expect(searchObj.meta.selectedTraceStream).toBe("already-chosen-stream");
});
it("handles an empty stream list without throwing", async () => {
mockGetStreams.mockResolvedValue({ list: [] });
const { getTracesStreams, tracesStreams, filteredTracesStreamOptions } = useViewTraceAction(
gt,
searchObj,
);
await getTracesStreams();
await flushPromises();
expect(tracesStreams.value).toEqual([]);
expect(filteredTracesStreamOptions.value).toEqual([]);
// Nothing to select, so the existing (empty) selection is left alone.
// Assigning `undefined` here used to make the *next* call throw on
// `selectedTraceStream.length`.
expect(searchObj.meta.selectedTraceStream).toBe("");
});
it("does not throw and leaves loading false when the fetch rejects", async () => {
mockGetStreams.mockRejectedValue(new Error("Fetch failed"));
const { getTracesStreams, isTracesStreamsLoading, tracesStreams } = useViewTraceAction(
gt,
searchObj,
);
await expect(getTracesStreams()).resolves.toBeUndefined();
await flushPromises();
expect(isTracesStreamsLoading.value).toBe(false);
expect(tracesStreams.value).toEqual([]);
expect(searchObj.meta.selectedTraceStream).toBe("");
});
it("does not throw when getStreams throws synchronously", async () => {
mockGetStreams.mockImplementation(() => {
throw new Error("boom");
});
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const { getTracesStreams, isTracesStreamsLoading } = useViewTraceAction(gt, searchObj);
await expect(getTracesStreams()).resolves.toBeUndefined();
expect(isTracesStreamsLoading.value).toBe(false);
expect(consoleError).toHaveBeenCalledWith("Failed to get traces streams", expect.any(Error));
});
});
describe("filterStreamFn", () => {
const seedStreams = (streams: string[]) => {
const action = useViewTraceAction(gt, searchObj);
action.tracesStreams.value = streams;
return action;
};
it("keeps only the streams matching the term", () => {
const { filterStreamFn, filteredTracesStreamOptions } = seedStreams([
"trace-stream1",
"trace-stream2",
"other-stream",
]);
filterStreamFn("trace");
expect(filteredTracesStreamOptions.value).toEqual(["trace-stream1", "trace-stream2"]);
});
it("matches case-insensitively in both directions", () => {
const { filterStreamFn, filteredTracesStreamOptions } = seedStreams([
"Trace-Stream1",
"trace-stream2",
"Other-Stream",
]);
filterStreamFn("TRACE");
expect(filteredTracesStreamOptions.value).toEqual(["Trace-Stream1", "trace-stream2"]);
});
it("matches on a substring anywhere in the name", () => {
const { filterStreamFn, filteredTracesStreamOptions } = seedStreams([
"prod-traces",
"dev-logs",
]);
filterStreamFn("traces");
expect(filteredTracesStreamOptions.value).toEqual(["prod-traces"]);
});
it("returns every stream for an empty term", () => {
const { filterStreamFn, filteredTracesStreamOptions } = seedStreams(["stream1", "stream2"]);
filterStreamFn("");
expect(filteredTracesStreamOptions.value).toEqual(["stream1", "stream2"]);
});
it("returns every stream when called with no argument", () => {
const { filterStreamFn, filteredTracesStreamOptions } = seedStreams(["stream1", "stream2"]);
filterStreamFn();
expect(filteredTracesStreamOptions.value).toEqual(["stream1", "stream2"]);
});
it("returns nothing when no stream matches", () => {
const { filterStreamFn, filteredTracesStreamOptions } = seedStreams(["stream1", "stream2"]);
filterStreamFn("no-such-stream");
expect(filteredTracesStreamOptions.value).toEqual([]);
});
it("narrows further on a second, longer term", () => {
const { filterStreamFn, filteredTracesStreamOptions } = seedStreams([
"trace-stream1",
"trace-stream2",
"other-stream",
]);
filterStreamFn("trace");
filterStreamFn("trace-stream2");
expect(filteredTracesStreamOptions.value).toEqual(["trace-stream2"]);
});
it("filters against the full list, not the previously filtered one", () => {
const { filterStreamFn, filteredTracesStreamOptions } = seedStreams([
"trace-stream1",
"other-stream",
]);
filterStreamFn("trace");
filterStreamFn("other");
expect(filteredTracesStreamOptions.value).toEqual(["other-stream"]);
});
it("returns nothing when no streams have been loaded", () => {
const { filterStreamFn, filteredTracesStreamOptions } = useViewTraceAction(gt, searchObj);
filterStreamFn("trace");
expect(filteredTracesStreamOptions.value).toEqual([]);
});
});
});
@@ -0,0 +1,116 @@
// 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 { ref } from "vue";
import { useStore } from "vuex";
import type { TranslateFn } from "@/types/i18n";
import useStreams from "@/composables/useStreams";
import type { SearchObject } from "@/composables/useLogs/searchState";
/**
* State behind the "View Trace" action — the traces-stream picker and the
* button beside it.
*
* Lives in a composable because two components render the same action from the
* same rules: `JsonPreview` shows it inline under an expanded log row, and
* `DetailTable` shows it in the row-detail drawer's header. Copying the gate
* into both is how `service_streams_enabled` ended up hand-rolled in three
* files and drifted.
*
* Each caller gets its own refs. `searchObj` is injected rather than resolved
* here so the picker writes the chosen stream back to the caller's own search
* state — it is shared module state in the app, so the selection carries across
* both renderings.
*/
export default function useViewTraceAction(t: TranslateFn, searchObj: SearchObject) {
const store = useStore();
const { getStreams } = useStreams(t);
const tracesStreams = ref<string[]>([]);
const filteredTracesStreamOptions = ref<string[]>([]);
const isTracesStreamsLoading = ref(false);
// Holds the trace-id value rather than a strict boolean: the gate below ends
// in the record lookup, and callers only ever use it for truthiness.
const showViewTraceBtn = ref<string | boolean | undefined>(false);
// Latches once a fetch has completed, successfully or not. `filteredTraces-
// StreamOptions.length` cannot serve as the guard: an org with no traces
// streams leaves it empty forever, which would refetch on every record.
const hasLoadedTracesStreams = ref(false);
const getTracesStreams = async () => {
isTracesStreamsLoading.value = true;
try {
// Awaited inside the try so a rejection lands in the catch below instead
// of escaping as an unhandled rejection on a promise nobody holds.
const res = (await getStreams("traces", false)) as { list: { name: string }[] };
tracesStreams.value = (res?.list ?? []).map((option) => option.name);
filteredTracesStreamOptions.value = [...tracesStreams.value];
// Only default the selection when there is something to select —
// assigning `undefined` here used to make the next call throw on
// `selectedTraceStream.length`.
if (!searchObj.meta.selectedTraceStream?.length && tracesStreams.value.length)
searchObj.meta.selectedTraceStream = tracesStreams.value[0];
} catch (err: unknown) {
console.error("Failed to get traces streams", err);
} finally {
hasLoadedTracesStreams.value = true;
isTracesStreamsLoading.value = false;
}
};
/**
* Decide whether the action is offered for `record`, and lazily load the
* traces streams the picker needs the first time it is.
*/
const setViewTraceBtn = (record: Record<string, unknown> | null | undefined) => {
// Hide view traces button when service_streams_enabled is true
const serviceStreamsEnabled = store.state.zoConfig.service_streams_enabled !== false;
// `hiddenMenus` is seeded as an array and only replaced with a Set once
// MainLayout has read `custom_hide_menus`, so accept either shape rather
// than assuming `.has` exists.
const hiddenMenus: Set<string> | string[] | undefined = store.state.hiddenMenus;
const tracesMenuHidden = Array.isArray(hiddenMenus)
? hiddenMenus.includes("traces")
: Boolean(hiddenMenus?.has("traces"));
showViewTraceBtn.value =
!tracesMenuHidden && // Check if traces menu is hidden
!serviceStreamsEnabled && // Hide when service streams is enabled
(record?.[store.state.organizationData?.organizationSettings?.trace_id_field_name] as
string | undefined);
if (showViewTraceBtn.value && !hasLoadedTracesStreams.value && !isTracesStreamsLoading.value)
getTracesStreams();
};
const filterStreamFn = (val: string = "") => {
filteredTracesStreamOptions.value = tracesStreams.value.filter((stream: string) => {
return stream.toLowerCase().indexOf(val.toLowerCase()) > -1;
});
};
return {
tracesStreams,
filteredTracesStreamOptions,
isTracesStreamsLoading,
showViewTraceBtn,
getTracesStreams,
setViewTraceBtn,
filterStreamFn,
};
}
+1
View File
@@ -8885,6 +8885,7 @@
},
"openInFullView": "In Vollansicht öffnen",
"viewLogs": "Protokolle anzeigen",
"viewLogsUnavailable": "Logs für diesen Span konnten nicht geöffnet werden.",
"backToLogs": "Zurück zu Logs",
"openInTraces": "In Traces öffnen",
"loadingStream": "Stream wird geladen...",
+1
View File
@@ -8885,6 +8885,7 @@
},
"openInFullView": "Open in full view",
"viewLogs": "View Logs",
"viewLogsUnavailable": "Could not open logs for this span.",
"backToLogs": "Back to Logs",
"openInTraces": "Open in Traces",
"loadingStream": "Loading Stream...",
+1
View File
@@ -8885,6 +8885,7 @@
},
"openInFullView": "Abrir a la vista",
"viewLogs": "Ver registros",
"viewLogsUnavailable": "No se pudieron abrir los registros para este span.",
"backToLogs": "Volver a los registros",
"openInTraces": "Abrir en Traces",
"loadingStream": "Cargando Stream...",
+1
View File
@@ -8885,6 +8885,7 @@
},
"openInFullView": "Ouvrir en plein écran",
"viewLogs": "Afficher les journaux",
"viewLogsUnavailable": "Impossible d'ouvrir les journaux pour ce span.",
"backToLogs": "Retour aux journaux",
"openInTraces": "Ouvrir dans Traces",
"loadingStream": "Chargement du flux...",
+1
View File
@@ -8885,6 +8885,7 @@
},
"openInFullView": "Apri a piena vista",
"viewLogs": "Visualizza i log",
"viewLogsUnavailable": "Impossibile aprire i log per questo span.",
"backToLogs": "Torna ai log",
"openInTraces": "Apri in Traces",
"loadingStream": "Caricamento dello stream...",
+1
View File
@@ -8885,6 +8885,7 @@
},
"openInFullView": "フルビューで開く",
"viewLogs": "ログを表示",
"viewLogsUnavailable": "このスパンのログを開けませんでした。",
"backToLogs": "ログに戻る",
"openInTraces": "トレースで開く",
"loadingStream": "ストリームをロード中...",
+1
View File
@@ -8885,6 +8885,7 @@
},
"openInFullView": "전체 화면으로 열기",
"viewLogs": "로그 보기",
"viewLogsUnavailable": "이 스팬의 로그를 열 수 없습니다.",
"backToLogs": "로그로 돌아가기",
"openInTraces": "오픈 인 트레이스",
"loadingStream": "스트림 로드 중...",
+1
View File
@@ -8885,6 +8885,7 @@
},
"openInFullView": "In volledige weergave openen",
"viewLogs": "Logboeken bekijken",
"viewLogsUnavailable": "Kan de logs voor deze span niet openen.",
"backToLogs": "Terug naar de logboeken",
"openInTraces": "Openen in Traces",
"loadingStream": "Stream wordt geladen...",
+1
View File
@@ -8885,6 +8885,7 @@
},
"openInFullView": "Otwórz w pełnym widoku",
"viewLogs": "Pokaż logi",
"viewLogsUnavailable": "Nie udało się otworzyć logów dla tego spanu.",
"backToLogs": "Powrót do logów",
"openInTraces": "Otwórz w śladach",
"loadingStream": "Ładowanie strumienia...",
+1
View File
@@ -8885,6 +8885,7 @@
},
"openInFullView": "Abrir à vista completa",
"viewLogs": "Exibir registros",
"viewLogsUnavailable": "Não foi possível abrir os logs para este span.",
"backToLogs": "Voltar aos registros",
"openInTraces": "Abrir no Traces",
"loadingStream": "Carregando Stream...",
+1
View File
@@ -8885,6 +8885,7 @@
},
"openInFullView": "Открыть в полном виде",
"viewLogs": "Просмотр логов",
"viewLogsUnavailable": "Не удалось открыть логи для этого span.",
"backToLogs": "Назад к логам",
"openInTraces": "Открыть в трейсах",
"loadingStream": "Загрузка потока...",
+1
View File
@@ -8885,6 +8885,7 @@
},
"openInFullView": "Tam görünümde aç",
"viewLogs": "Günlükleri Görüntüle",
"viewLogsUnavailable": "Bu span için günlükler açılamadı.",
"backToLogs": "Günlüklere Dön",
"openInTraces": "Traces'te aç",
"loadingStream": "Akış Yükleniyor...",
+1
View File
@@ -8885,6 +8885,7 @@
},
"openInFullView": "Mở toàn màn hình",
"viewLogs": "Xem Nhật ký",
"viewLogsUnavailable": "Không thể mở nhật ký cho span này.",
"backToLogs": "Quay lại Nhật ký",
"openInTraces": "Mở trong Dấu vết",
"loadingStream": "Đang tải Luồng...",
+1
View File
@@ -8885,6 +8885,7 @@
},
"openInFullView": "以全视图打开",
"viewLogs": "查看日志",
"viewLogsUnavailable": "无法打开此跨度的日志。",
"backToLogs": "返回日志",
"openInTraces": "在追踪中打开",
"loadingStream": "正在加载数据流...",
+1
View File
@@ -8880,6 +8880,7 @@
},
"openInFullView": "在完整檢視中開啟",
"viewLogs": "檢視日誌",
"viewLogsUnavailable": "無法開啟此 span 的日誌。",
"backToLogs": "返回日誌",
"openInTraces": "在追蹤中開啟",
"loadingStream": "載入串流中...",
+260 -73
View File
@@ -38,6 +38,10 @@ vi.mock("@/composables/useLogs/searchState", () => ({
selectedFields: ["_timestamp"],
},
},
meta: {
// The header's traces-stream picker writes the chosen stream back here.
selectedTraceStream: "",
},
},
}),
}));
@@ -63,6 +67,15 @@ vi.mock("@/lib/feedback/Toast/useToast", () => ({
toast: vi.fn(),
}));
// The only I/O behind useViewTraceAction is useStreams. Stubbing it here keeps
// the REAL useViewTraceAction in play, so the View Trace gate under test is the
// production one rather than a re-implementation.
const getStreams = vi.hoisted(() => vi.fn());
vi.mock("@/composables/useStreams", () => ({
default: () => ({ getStreams }),
}));
// Mock clipboard utils
vi.mock("@/utils/clipboard", async () => {
const actual = await vi.importActual("@/utils/clipboard");
@@ -77,7 +90,12 @@ vi.mock("./JsonPreview.vue", () => ({
default: {
name: "JsonPreview",
template: "<div data-test='json-preview'><slot /></div>",
props: ["value", "showCopyButton", "mode"],
props: {
value: { type: null },
showCopyButton: { type: Boolean, default: false },
mode: { type: String, default: "" },
hideViewTrace: { type: Boolean, default: false },
},
emits: [
"copy",
"add-field-to-table",
@@ -135,6 +153,82 @@ describe("DetailTable Component", () => {
streamType: "logs",
};
// Shared mount config so extra mounts inside individual tests get exactly the
// same stubs as the default wrapper built in beforeEach.
const globalMountOptions = {
provide: {
store: store,
},
plugins: [i18n, router],
stubs: {
OTabs: {
template: "<div><slot /></div>",
props: ["modelValue"],
emits: ["update:modelValue"],
},
OTab: {
template:
"<div :data-test=\"$attrs['data-test']\" @click=\"$emit('click')\"><slot /></div>",
props: ["name", "label"],
emits: ["click"],
},
OTabPanels: {
template: "<div :data-test=\"$attrs['data-test']\"><slot /></div>",
props: ["modelValue"],
},
OTabPanel: {
template: "<div><slot /></div>",
props: ["name"],
},
OSwitch: {
template: '<div :data-test="$attrs[\'data-test\']" @click="toggle"><slot /></div>',
props: ["modelValue", "label"],
methods: {
toggle() {
this.$emit("update:modelValue", !this.modelValue);
},
},
emits: ["update:modelValue"],
},
OButton: {
template:
'<button @click="$emit(\'click\')" :data-test="$attrs[\'data-test\']" :disabled="$attrs.disabled"><slot /></button>',
emits: ["click"],
},
ODropdown: true,
ODropdownItem: true,
ODropdownSeparator: true,
OSelect: {
template:
'<select class="o-select" :data-test="$attrs[\'data-test\']" @change="onChange"><option v-for="opt in options" :key="opt" :value="opt">{{ opt }}</option></select>',
props: ["modelValue", "options"],
methods: {
onChange(e: any) {
this.$emit("update:modelValue", e.target.value);
},
},
emits: ["update:modelValue"],
},
OIcon: {
template: '<div class="OIcon"><slot /></div>',
},
OSpinner: true,
OSeparator: true,
OCardSection: {
template: "<div><slot /></div>",
},
OLogsHighLighting: true,
OChunkedContent: true,
OTelemetryCorrelationDashboard: true,
OCorrelatedLogsTable: true,
O2AIContextAddBtn: {
template:
'<div data-test="o2ai-context-btn" @click="$emit(\'sendToAiChat\')"><slot /></div>',
emits: ["sendToAiChat"],
},
},
};
beforeEach(async () => {
// Clear localStorage before each test
window.localStorage.clear();
@@ -147,78 +241,7 @@ describe("DetailTable Component", () => {
wrapper = mount(DetailTable, {
attachTo: "#app",
props: defaultProps,
global: {
provide: {
store: store,
},
plugins: [i18n, router],
stubs: {
OTabs: {
template: "<div><slot /></div>",
props: ["modelValue"],
emits: ["update:modelValue"],
},
OTab: {
template:
"<div :data-test=\"$attrs['data-test']\" @click=\"$emit('click')\"><slot /></div>",
props: ["name", "label"],
emits: ["click"],
},
OTabPanels: {
template: "<div :data-test=\"$attrs['data-test']\"><slot /></div>",
props: ["modelValue"],
},
OTabPanel: {
template: "<div><slot /></div>",
props: ["name"],
},
OSwitch: {
template: '<div :data-test="$attrs[\'data-test\']" @click="toggle"><slot /></div>',
props: ["modelValue", "label"],
methods: {
toggle() {
this.$emit("update:modelValue", !this.modelValue);
},
},
emits: ["update:modelValue"],
},
OButton: {
template:
'<button @click="$emit(\'click\')" :data-test="$attrs[\'data-test\']" :disabled="$attrs.disabled"><slot /></button>',
},
ODropdown: true,
ODropdownItem: true,
ODropdownSeparator: true,
OSelect: {
template:
'<select class="o-select" :data-test="$attrs[\'data-test\']" @change="onChange"><option v-for="opt in options" :key="opt" :value="opt">{{ opt }}</option></select>',
props: ["modelValue", "options"],
methods: {
onChange(e: any) {
this.$emit("update:modelValue", e.target.value);
},
},
emits: ["update:modelValue"],
},
OIcon: {
template: '<div class="OIcon"><slot /></div>',
},
OSpinner: true,
OSeparator: true,
OCardSection: {
template: "<div><slot /></div>",
},
OLogsHighLighting: true,
OChunkedContent: true,
OTelemetryCorrelationDashboard: true,
OCorrelatedLogsTable: true,
O2AIContextAddBtn: {
template:
'<div data-test="o2ai-context-btn" @click="$emit(\'sendToAiChat\')"><slot /></div>',
emits: ["sendToAiChat"],
},
},
},
global: globalMountOptions,
});
await flushPromises();
});
@@ -1002,4 +1025,168 @@ describe("DetailTable Component", () => {
expect(result[0].resolvedUrl).toBe("https://example.com/search?q=hello%20world%26foo%3Dbar");
});
});
// The "View Trace" action used to live inside JsonPreview, i.e. inside the JSON
// tab. It now sits in this component's header row next to the wrap toggle so it
// stays reachable from every tab, and JsonPreview is told to hide its own copy.
describe("View Trace action in the header", () => {
const TRACE_ID = "8a3f1c2d9b4e7a60";
const TRACES_STREAMS = ["default_traces", "otlp_traces"];
const SELECT = '[data-test="log-detail-view-trace-stream-select"]';
const BUTTON = '[data-test="log-detail-view-trace-btn"]';
const WRAP_TOGGLE = '[data-test="log-detail-wrap-values-toggle-btn"]';
// A record that satisfies the gate: it carries the org's trace id field.
const recordWithTraceId = { ...defaultProps.modelValue, trace_id: TRACE_ID };
let traceWrapper: any;
const mountDetailTable = (props: Record<string, any> = {}) =>
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);
});
});
});
+49
View File
@@ -36,6 +36,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="logs-detail-ai-context-btn"
@sendToAiChat="sendToAiChat(JSON.stringify(rowData))"
/>
<!-- Lives in the header rather than inside the JSON tab so the action
stays reachable from every tab. JsonPreview is told to hide its own
copy via `hide-view-trace`. -->
<div
v-if="showViewTraceBtn && (tracesStreams.length || isTracesStreamsLoading)"
class="flex shrink-0 items-center gap-2"
>
<OSelect
data-test="log-detail-view-trace-stream-select"
v-model="searchObj.meta.selectedTraceStream"
:options="tracesStreams"
class="w-40! shrink-0"
:loading="isTracesStreamsLoading"
:disabled="isTracesStreamsLoading"
size="sm"
/>
<OButton
data-test="log-detail-view-trace-btn"
size="xs"
variant="outline"
icon-left="account-tree"
@click="viewTrace"
>{{ t("search.viewTrace") }}</OButton
>
</div>
<OSwitch
v-show="tab === 'table'"
data-test="log-detail-wrap-values-toggle-btn"
@@ -69,6 +94,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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,
+200 -1
View File
@@ -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<string, any> = {}) =>
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);
});
});
});
});
+36 -49
View File
@@ -34,7 +34,9 @@
<!-- Stream picker and its action read as one control, sized to sit level
with the toolbar buttons above rather than towering over them. -->
<div
v-if="showViewTraceBtn && (tracesStreams.length || isTracesStreamsLoading)"
v-if="
!hideViewTrace && showViewTraceBtn && (tracesStreams.length || isTracesStreamsLoading)
"
class="mb-1.5 flex items-center gap-2"
>
<OSelect
@@ -258,7 +260,6 @@ import NotEqualIcon from "@/components/icons/NotEqualIcon.vue";
import { raw, useI18nTyped } from "@/types/i18n";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import { useRouter } from "vue-router";
import useStreams from "@/composables/useStreams";
import AppTabs from "@/components/common/AppTabs.vue";
import searchService from "@/services/search";
import { generateTraceContext } from "@/utils/zincutils";
@@ -267,6 +268,7 @@ import config from "@/aws-exports";
import LogsHighLighting from "@/components/logs/LogsHighLighting.vue";
import ChunkedContent from "@/components/logs/ChunkedContent.vue";
import { searchState } from "@/composables/useLogs/searchState";
import useViewTraceAction from "@/composables/useLogs/useViewTraceAction";
import { useServiceCorrelation } from "@/composables/useServiceCorrelation";
import OButton from "@/lib/core/Button/OButton.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
@@ -312,6 +314,12 @@ export default {
type: Boolean,
default: false,
},
// Set by DetailTable's drawer, which renders the View Trace action in its
// own header row instead so it stays reachable from every tab.
hideViewTrace: {
type: Boolean,
default: false,
},
hideSearchTermActions: {
type: Boolean,
default: false,
@@ -356,18 +364,10 @@ export default {
const streamSearchValue = ref<string>("");
const { getStreams } = useStreams(t);
const filteredTracesStreamOptions = ref([]);
const router = useRouter();
const tracesStreams = ref([]);
const queryEditorRef = ref<any>();
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
+7 -9
View File
@@ -797,15 +797,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Map View with Pattern/Span Toggle -->
<div v-if="activeTab === 'map'" class="flex h-full min-h-0 w-full flex-1 flex-col">
<!-- Chart Container -->
<div class="flex min-h-0 flex-1 items-center justify-center">
<div class="h-full w-full p-2.5 text-center">
<ChartRenderer
ref="chartRendererRef"
data-test="trace-details-service-map-chart"
:data="traceServiceMapChartOptions"
class="trace-chart-height h-50! min-h-50! w-full!"
/>
</div>
<div class="min-h-0 flex-1 overflow-hidden p-2.5">
<ChartRenderer
ref="chartRendererRef"
data-test="trace-details-service-map-chart"
:data="traceServiceMapChartOptions"
class="h-full"
/>
</div>
</div>
</div>
+240 -3
View File
@@ -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<Record<string, unknown>>("@/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<string, unknown>) {
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);
});
});
});
});
+29 -2
View File
@@ -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);
};