mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 08:09:24 +00:00
fix(rum): display HTTP server operations in session traces (#14155)
This commit is contained in:
@@ -108,15 +108,27 @@ function createTraceMetadata(overrides: Record<string, any> = {}) {
|
||||
* the fetchTraceMetadata Promise (setTimeout creates a macrotask that
|
||||
* flushPromises() does not drain).
|
||||
*/
|
||||
function setupSuccessfulMocks(rumHits?: any[], traceMetadataHits?: any[]) {
|
||||
function setupSuccessfulMocks(rumHits?: any[], traceMetadataHits?: any[], operationHits?: any[]) {
|
||||
mockSearch.mockReset();
|
||||
mockFetchQueryDataWithHttpStream.mockReset();
|
||||
|
||||
const hits = rumHits ?? [createRumHit()];
|
||||
const metadata = traceMetadataHits ?? [createTraceMetadata()];
|
||||
|
||||
mockSearch.mockImplementation((_params: any, source: string) => {
|
||||
mockSearch.mockImplementation((params: any, source: string) => {
|
||||
if (source === "RUM") {
|
||||
if (params.page_type === "traces") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
hits:
|
||||
operationHits ??
|
||||
metadata.map((hit) => ({
|
||||
trace_id: hit.trace_id,
|
||||
operation_name: hit.first_event?.operation_name,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ data: { hits } });
|
||||
}
|
||||
return Promise.resolve({ data: { hits: [] } });
|
||||
@@ -301,8 +313,8 @@ describe("PlayerTracesTab", () => {
|
||||
expect(wrapper.find('[data-test="rum-player-traces-tab-table"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("should display the route path in the table row", () => {
|
||||
expect(wrapper.text()).toContain("/products");
|
||||
it("should display the HTTP server operation in the table row", () => {
|
||||
expect(wrapper.text()).toContain("GET /api/products");
|
||||
});
|
||||
|
||||
it("gives each row its own stream and fetches metadata per distinct stream", async () => {
|
||||
@@ -316,17 +328,26 @@ describe("PlayerTracesTab", () => {
|
||||
// RUM hits for both ids; metadata mock answers per queried stream.
|
||||
mockSearch.mockReset();
|
||||
mockFetchQueryDataWithHttpStream.mockReset();
|
||||
mockSearch.mockImplementation((_params: any, source: string) =>
|
||||
mockSearch.mockImplementation((params: any, source: string) =>
|
||||
Promise.resolve(
|
||||
source === "RUM"
|
||||
? {
|
||||
data: {
|
||||
hits: [
|
||||
createRumHit({ _trace_id: P1, _view_url: "https://example.com/pay" }),
|
||||
createRumHit({ _trace_id: C1, _view_url: "https://example.com/checkout" }),
|
||||
],
|
||||
},
|
||||
}
|
||||
? params.page_type === "traces"
|
||||
? {
|
||||
data: {
|
||||
hits: [
|
||||
{ trace_id: P1, operation_name: "POST /api/payments" },
|
||||
{ trace_id: C1, operation_name: "POST /api/checkout" },
|
||||
],
|
||||
},
|
||||
}
|
||||
: {
|
||||
data: {
|
||||
hits: [
|
||||
createRumHit({ _trace_id: P1, _view_url: "https://example.com/pay" }),
|
||||
createRumHit({ _trace_id: C1, _view_url: "https://example.com/checkout" }),
|
||||
],
|
||||
},
|
||||
}
|
||||
: { data: { hits: [] } },
|
||||
),
|
||||
);
|
||||
@@ -350,12 +371,12 @@ describe("PlayerTracesTab", () => {
|
||||
expect(streamsQueried.sort()).toEqual(["checkout_traces", "payments_traces"]);
|
||||
|
||||
// both rows render, each carrying its own stream context
|
||||
expect(wrapper.text()).toContain("/pay");
|
||||
expect(wrapper.text()).toContain("/checkout");
|
||||
expect(wrapper.text()).toContain("POST /api/payments");
|
||||
expect(wrapper.text()).toContain("POST /api/checkout");
|
||||
|
||||
// clicking the checkout row opens the embedded view against ITS stream
|
||||
const rows = wrapper.findAll('[data-test^="table-row-"]');
|
||||
const checkoutRow = rows.find((r: any) => r.text().includes("/checkout"));
|
||||
const checkoutRow = rows.find((r: any) => r.text().includes("POST /api/checkout"));
|
||||
expect(checkoutRow).toBeDefined();
|
||||
await checkoutRow!.trigger("click");
|
||||
await flushPromises();
|
||||
@@ -443,7 +464,7 @@ describe("PlayerTracesTab", () => {
|
||||
// Row survives the metadata filter (previously dropped: raw 31-char key
|
||||
// never matched the 32-char metadata key)
|
||||
expect(wrapper.find('[data-test="rum-player-traces-tab-table"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("/products");
|
||||
expect(wrapper.text()).toContain("GET /api/products");
|
||||
|
||||
// The traces-stream metadata query was filtered on the padded id
|
||||
const queryReq = mockFetchQueryDataWithHttpStream.mock.calls[0][0].queryReq;
|
||||
@@ -597,11 +618,11 @@ describe("PlayerTracesTab", () => {
|
||||
expect(wrapper.find('[data-test="rum-player-traces-tab-table"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("should show the selected trace route in detail header", async () => {
|
||||
it("should show the selected HTTP server operation in detail header", async () => {
|
||||
await wrapper.find('[data-test="table-row-0"]').trigger("click");
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain("/products");
|
||||
expect(wrapper.text()).toContain("GET /api/products");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -711,6 +732,80 @@ describe("PlayerTracesTab", () => {
|
||||
// =========================================================================
|
||||
|
||||
describe("trace metadata", () => {
|
||||
it("replaces an internal trace summary with the HTTP server operation", async () => {
|
||||
wrapper.unmount();
|
||||
setupSuccessfulMocks(
|
||||
[createRumHit()],
|
||||
[
|
||||
createTraceMetadata({
|
||||
first_event: {
|
||||
service_name: "product-catalog",
|
||||
operation_name: "middleware - query",
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
{
|
||||
trace_id: "trace-abc123def456",
|
||||
operation_name: "POST /api/products",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain("POST /api/products");
|
||||
expect(wrapper.text()).not.toContain("middleware - query");
|
||||
});
|
||||
|
||||
it("falls back to the trace summary when operation enrichment fails", async () => {
|
||||
wrapper.unmount();
|
||||
setupSuccessfulMocks();
|
||||
mockSearch.mockImplementation((params: any) => {
|
||||
if (params.page_type === "traces") {
|
||||
return Promise.reject(new Error("Operation search failed"));
|
||||
}
|
||||
return Promise.resolve({ data: { hits: [createRumHit()] } });
|
||||
});
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain("GET /api/products");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"HTTP server operation fetch failed:",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it("queries the earliest routed server operation for all displayed trace IDs", async () => {
|
||||
wrapper.unmount();
|
||||
setupSuccessfulMocks(
|
||||
[createRumHit({ _trace_id: "trace-1" }), createRumHit({ _trace_id: "trace-2" })],
|
||||
[
|
||||
createTraceMetadata({ trace_id: "trace-1" }),
|
||||
createTraceMetadata({ trace_id: "trace-2" }),
|
||||
],
|
||||
);
|
||||
|
||||
wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const operationSearch = mockSearch.mock.calls.find(
|
||||
([params]) => params.page_type === "traces",
|
||||
);
|
||||
const sql = operationSearch?.[0].query.query.sql;
|
||||
expect(sql).toContain("trace_id IN ('trace-1','trace-2')");
|
||||
expect(sql).toContain("first_value(operation_name ORDER BY _timestamp ASC)");
|
||||
expect(sql).toContain("span_kind='2'");
|
||||
expect(sql).toContain("http_route IS NOT NULL");
|
||||
expect(
|
||||
mockSearch.mock.calls.filter(([params]) => params.page_type === "traces"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should display computed e2e duration in the table cell", async () => {
|
||||
wrapper.unmount();
|
||||
|
||||
@@ -824,6 +919,15 @@ describe("PlayerTracesTab", () => {
|
||||
it("should return the original string for an invalid URL", () => {
|
||||
expect((wrapper.vm as any).shortRoute("not-a-url")).toBe("not-a-url");
|
||||
});
|
||||
|
||||
it("falls back to the browser route when operation metadata is unavailable", () => {
|
||||
expect(
|
||||
(wrapper.vm as any).traceDisplayName({
|
||||
route: "https://example.com/products",
|
||||
metadata: { rootOperation: "unknown" },
|
||||
}),
|
||||
).toBe("/products");
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -72,7 +72,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OIcon name="arrow-back" size="sm" />
|
||||
</OButton>
|
||||
<code class="text-text-secondary min-w-0 flex-1 truncate text-sm">{{
|
||||
shortRoute(selectedTrace.route) || selectedTrace.label
|
||||
traceDisplayName(selectedTrace)
|
||||
}}</code>
|
||||
<div class="flex flex-shrink-0 items-center gap-1.5">
|
||||
<span
|
||||
@@ -188,8 +188,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-route="{ row }">
|
||||
<span class="block truncate font-mono text-xs" :title="row.route">
|
||||
{{ shortRoute(row.route) }}
|
||||
<span class="block truncate font-mono text-xs" :title="traceDisplayName(row)">
|
||||
{{ traceDisplayName(row) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-duration="{ row }">
|
||||
@@ -223,6 +223,7 @@ import useHttpStreaming from "@/composables/useStreamingSearch";
|
||||
import useCorrelatedTracesStream from "@/composables/rum/useCorrelatedTracesStream";
|
||||
import { traceQueryWindow } from "@/utils/rum/traceWindow";
|
||||
import type { TraceTimeRange } from "@/ts/interfaces/traces/traceTimeRange.types";
|
||||
import { quoteSqlIdentifierIfNeeded } from "@/utils/query/sqlIdentifiers";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
|
||||
@@ -294,7 +295,7 @@ const traceColumns = computed(() => [
|
||||
{
|
||||
id: "route",
|
||||
header: t("rum.route"),
|
||||
accessorFn: (row: any) => shortRoute(row.route),
|
||||
accessorFn: (row: any) => traceDisplayName(row),
|
||||
size: 400,
|
||||
minSize: 80,
|
||||
maxSize: 800,
|
||||
@@ -334,6 +335,11 @@ function shortRoute(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function traceDisplayName(trace: any): string {
|
||||
const operation = trace.metadata?.httpOperation || trace.metadata?.rootOperation;
|
||||
return operation && operation !== "unknown" ? operation : shortRoute(trace.route) || trace.label;
|
||||
}
|
||||
|
||||
// Converts nanosecond trace timestamp to millisecond offset from session start,
|
||||
// matching SessionViewer's formatTimeDifference(event.date, session.start_time) pattern.
|
||||
function traceRelativeTimeMs(startTimeNs: number): number {
|
||||
@@ -407,7 +413,7 @@ async function fetchTraceMetadata(
|
||||
? `trace_id='${safeTraceIds[0]}'`
|
||||
: `trace_id IN (${safeTraceIds.map((id) => `'${id}'`).join(",")})`;
|
||||
|
||||
return new Promise<Record<string, any>>((resolve, reject) => {
|
||||
const metadata = await new Promise<Record<string, any>>((resolve, reject) => {
|
||||
const traceId = generateTraceContext().traceId;
|
||||
activeTraceIds.push(traceId);
|
||||
const untrack = () => {
|
||||
@@ -457,6 +463,37 @@ async function fetchTraceMetadata(
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const stream = quoteSqlIdentifierIfNeeded(streamName);
|
||||
const timestamp = quoteSqlIdentifierIfNeeded(store.state.zoConfig.timestamp_column);
|
||||
const operationResponse = await searchService.search(
|
||||
{
|
||||
org_identifier: orgId,
|
||||
query: {
|
||||
query: {
|
||||
sql: `SELECT trace_id, first_value(operation_name ORDER BY ${timestamp} ASC) AS operation_name FROM ${stream} WHERE ${filter} AND span_kind='2' AND http_route IS NOT NULL AND operation_name IS NOT NULL AND operation_name != '' GROUP BY trace_id`,
|
||||
start_time: searchStartTime,
|
||||
end_time: searchEndTime,
|
||||
from: 0,
|
||||
size: traceIds.length,
|
||||
},
|
||||
},
|
||||
page_type: "traces",
|
||||
},
|
||||
"RUM",
|
||||
);
|
||||
|
||||
for (const hit of operationResponse.data?.hits || []) {
|
||||
if (metadata[hit.trace_id] && hit.operation_name) {
|
||||
metadata[hit.trace_id].httpOperation = hit.operation_name;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("HTTP server operation fetch failed:", err);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
// An in-flight metadata fetch outlives this tab otherwise; cancel it on unmount.
|
||||
|
||||
Reference in New Issue
Block a user