fix(rum): display HTTP server operations in session traces (#14155)

This commit is contained in:
Mina Karam
2026-09-08 11:59:15 +00:00
committed by GitHub
parent 530fd8e606
commit 4f02ee269f
2 changed files with 165 additions and 24 deletions
+123 -19
View File
@@ -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");
});
});
// =========================================================================
+42 -5
View File
@@ -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.