diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/index.test.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/index.test.tsx
index 4f2903ea2061c..6900d40fdc7a3 100644
--- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/index.test.tsx
+++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/index.test.tsx
@@ -15,22 +15,25 @@ import { setUnifiedDocViewerServices } from '../../../../../plugin';
import type { UnifiedDocViewerServices } from '../../../../../types';
jest.mock('./waterfall_flyout/span_flyout', () => ({
- SpanFlyout: ({ traceId, spanId, _, activeSection }: any) => (
-
- ),
spanFlyoutId: 'spanFlyout',
}));
jest.mock('./waterfall_flyout/logs_flyout', () => ({
- LogsFlyout: ({ id, _ }: any) => ,
logsFlyoutId: 'logsFlyout',
}));
+jest.mock('./waterfall_flyout/document_detail_flyout', () => ({
+ DocumentDetailFlyout: ({ type, docId, traceId, activeSection }: any) => (
+
+ ),
+}));
+
describe('FullScreenWaterfall', () => {
const defaultProps: FullScreenWaterfallProps = {
traceId: 'test-trace-id',
diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/index.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/index.tsx
index e032d163eb542..ae9ef50c2286f 100644
--- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/index.tsx
+++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/index.tsx
@@ -20,10 +20,9 @@ import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types';
import React, { useCallback, useState } from 'react';
import { getUnifiedDocViewerServices } from '../../../../../plugin';
import type { TraceOverviewSections } from '../../doc_viewer_overview/overview';
-import type { logsFlyoutId as logsFlyoutIdType } from './waterfall_flyout/logs_flyout';
-import { LogsFlyout, logsFlyoutId } from './waterfall_flyout/logs_flyout';
-import type { spanFlyoutId as spanFlyoutIdType } from './waterfall_flyout/span_flyout';
-import { SpanFlyout, spanFlyoutId } from './waterfall_flyout/span_flyout';
+import { spanFlyoutId } from './waterfall_flyout/span_flyout';
+import { logsFlyoutId } from './waterfall_flyout/logs_flyout';
+import { DocumentDetailFlyout, type DocumentType } from './waterfall_flyout/document_detail_flyout';
export const EUI_FLYOUT_BODY_OVERFLOW_CLASS = 'euiFlyoutBody__overflow';
@@ -51,9 +50,7 @@ export const FullScreenWaterfall = ({
const { euiTheme } = useEuiTheme();
const [docId, setDocId] = useState(null);
const [docIndex, setDocIndex] = useState(undefined);
- const [activeFlyoutId, setActiveFlyoutId] = useState<
- typeof spanFlyoutIdType | typeof logsFlyoutIdType | null
- >(null);
+ const [activeFlyoutType, setActiveFlyoutType] = useState(null);
const [activeSection, setActiveSection] = useState();
const [scrollElement, setScrollElement] = useState(null);
@@ -88,7 +85,7 @@ export const FullScreenWaterfall = ({
}, []);
function handleCloseFlyout() {
- setActiveFlyoutId(null);
+ setActiveFlyoutType(null);
setActiveSection(undefined);
setDocId(null);
setDocIndex(undefined);
@@ -98,7 +95,7 @@ export const FullScreenWaterfall = ({
setActiveSection(undefined);
setDocId(nodeSpanId);
setDocIndex(undefined);
- setActiveFlyoutId(spanFlyoutId);
+ setActiveFlyoutType(spanFlyoutId);
}
function handleErrorClick(params: {
@@ -109,12 +106,12 @@ export const FullScreenWaterfall = ({
docIndex?: string;
}) {
if (params.errorCount > 1) {
- setActiveFlyoutId(spanFlyoutId);
+ setActiveFlyoutType(spanFlyoutId);
setActiveSection('errors-table');
setDocId(params.docId);
setDocIndex(undefined);
} else if (params.errorDocId) {
- setActiveFlyoutId(logsFlyoutId);
+ setActiveFlyoutType(logsFlyoutId);
setDocId(params.errorDocId);
setDocIndex(params.docIndex);
}
@@ -163,23 +160,16 @@ export const FullScreenWaterfall = ({
- {docId && activeFlyoutId ? (
- activeFlyoutId === spanFlyoutId ? (
-
- ) : (
-
- )
+ {docId && activeFlyoutType ? (
+
) : null}
);
diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/document_detail_flyout.test.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/document_detail_flyout.test.tsx
new file mode 100644
index 0000000000000..231d14f96695a
--- /dev/null
+++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/document_detail_flyout.test.tsx
@@ -0,0 +1,337 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { DocumentDetailFlyout, type DocumentDetailFlyoutProps } from './document_detail_flyout';
+import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
+import { buildDataTableRecord } from '@kbn/discover-utils';
+
+const mockSpanHit = buildDataTableRecord(
+ {
+ _id: 'span-doc-id',
+ _index: 'traces-apm-default',
+ _source: {
+ '@timestamp': '2023-01-01T00:00:00.000Z',
+ 'span.name': 'test-span',
+ },
+ },
+ dataViewMock
+);
+
+const mockLogHit = buildDataTableRecord(
+ {
+ _id: 'log-doc-id',
+ _index: 'logs-default',
+ _source: {
+ '@timestamp': '2023-01-01T00:00:00.000Z',
+ message: 'test log message',
+ },
+ },
+ dataViewMock
+);
+
+const mockUseDocumentFlyoutData = jest.fn();
+
+jest.mock('./use_document_flyout_data', () => ({
+ useDocumentFlyoutData: (params: any) => mockUseDocumentFlyoutData(params),
+}));
+
+jest.mock('./span_flyout', () => ({
+ spanFlyoutId: 'spanDetailFlyout',
+ SpanFlyoutContent: ({ hit, dataView, activeSection }: any) => (
+
+ Span Flyout Content
+
+ ),
+}));
+
+jest.mock('./logs_flyout', () => ({
+ logsFlyoutId: 'logsFlyout',
+ LogFlyoutContent: ({ hit, logDataView }: any) => (
+
+ Log Flyout Content
+
+ ),
+}));
+
+jest.mock('.', () => ({
+ WaterfallFlyout: ({ onCloseFlyout, dataView, hit, loading, title, children }: any) => (
+
+ {loading ? (
+
Loading...
+ ) : hit ? (
+ children
+ ) : (
+
No hit
+ )}
+
+ ),
+}));
+
+describe('DocumentDetailFlyout', () => {
+ const defaultSpanProps: DocumentDetailFlyoutProps = {
+ type: 'spanDetailFlyout',
+ docId: 'test-span-id',
+ traceId: 'test-trace-id',
+ dataView: dataViewMock,
+ onCloseFlyout: jest.fn(),
+ };
+
+ const defaultLogProps: DocumentDetailFlyoutProps = {
+ type: 'logsFlyout',
+ docId: 'test-log-id',
+ traceId: 'test-trace-id',
+ dataView: dataViewMock,
+ onCloseFlyout: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('hook calls', () => {
+ it('should call useDocumentFlyoutData with correct params for span type', () => {
+ mockUseDocumentFlyoutData.mockReturnValue({
+ type: 'spanDetailFlyout',
+ hit: mockSpanHit,
+ loading: false,
+ title: 'Span document',
+ logDataView: null,
+ error: null,
+ });
+
+ render();
+
+ expect(mockUseDocumentFlyoutData).toHaveBeenCalledWith({
+ type: 'spanDetailFlyout',
+ docId: 'test-span-id',
+ traceId: 'test-trace-id',
+ docIndex: undefined,
+ });
+ });
+
+ it('should call useDocumentFlyoutData with correct params for log type', () => {
+ mockUseDocumentFlyoutData.mockReturnValue({
+ type: 'logsFlyout',
+ hit: mockLogHit,
+ loading: false,
+ title: 'Log document',
+ logDataView: dataViewMock,
+ error: null,
+ });
+
+ render();
+
+ expect(mockUseDocumentFlyoutData).toHaveBeenCalledWith({
+ type: 'logsFlyout',
+ docId: 'test-log-id',
+ traceId: 'test-trace-id',
+ docIndex: 'logs-*',
+ });
+ });
+ });
+
+ describe('content rendering based on type', () => {
+ it('should render SpanFlyoutContent when type is span', () => {
+ mockUseDocumentFlyoutData.mockReturnValue({
+ type: 'spanDetailFlyout',
+ hit: mockSpanHit,
+ loading: false,
+ title: 'Span document',
+ logDataView: null,
+ error: null,
+ });
+
+ render();
+
+ expect(screen.getByTestId('spanFlyoutContent')).toBeInTheDocument();
+ expect(screen.queryByTestId('logFlyoutContent')).not.toBeInTheDocument();
+ });
+
+ it('should render LogFlyoutContent when type is log', () => {
+ mockUseDocumentFlyoutData.mockReturnValue({
+ type: 'logsFlyout',
+ hit: mockLogHit,
+ loading: false,
+ title: 'Log document',
+ logDataView: dataViewMock,
+ error: null,
+ });
+
+ render();
+
+ expect(screen.getByTestId('logFlyoutContent')).toBeInTheDocument();
+ expect(screen.queryByTestId('spanFlyoutContent')).not.toBeInTheDocument();
+ });
+
+ it('should pass activeSection to SpanFlyoutContent', () => {
+ mockUseDocumentFlyoutData.mockReturnValue({
+ type: 'spanDetailFlyout',
+ hit: mockSpanHit,
+ loading: false,
+ title: 'Span document',
+ logDataView: null,
+ error: null,
+ });
+
+ render();
+
+ const spanContent = screen.getByTestId('spanFlyoutContent');
+ expect(spanContent).toHaveAttribute('data-active-section', 'errors-table');
+ });
+ });
+
+ describe('WaterfallFlyout props', () => {
+ it('should pass correct props to WaterfallFlyout for span type', () => {
+ mockUseDocumentFlyoutData.mockReturnValue({
+ type: 'spanDetailFlyout',
+ hit: mockSpanHit,
+ loading: false,
+ title: 'Span document',
+ logDataView: null,
+ error: null,
+ });
+
+ render();
+
+ const flyout = screen.getByTestId('waterfallFlyout');
+ expect(flyout).toHaveAttribute('data-loading', 'false');
+ expect(flyout).toHaveAttribute('data-title', 'Span document');
+ expect(flyout).toHaveAttribute('data-has-hit', 'true');
+ });
+
+ it('should pass correct props to WaterfallFlyout for log type', () => {
+ mockUseDocumentFlyoutData.mockReturnValue({
+ type: 'logsFlyout',
+ hit: mockLogHit,
+ loading: false,
+ title: 'Log document',
+ logDataView: dataViewMock,
+ error: null,
+ });
+
+ render();
+
+ const flyout = screen.getByTestId('waterfallFlyout');
+ expect(flyout).toHaveAttribute('data-loading', 'false');
+ expect(flyout).toHaveAttribute('data-title', 'Log document');
+ expect(flyout).toHaveAttribute('data-has-hit', 'true');
+ });
+ });
+
+ describe('loading states', () => {
+ it('should show loading state when data is loading', () => {
+ mockUseDocumentFlyoutData.mockReturnValue({
+ type: 'spanDetailFlyout',
+ hit: null,
+ loading: true,
+ title: 'Span document',
+ logDataView: null,
+ error: null,
+ });
+
+ render();
+
+ const flyout = screen.getByTestId('waterfallFlyout');
+ expect(flyout).toHaveAttribute('data-loading', 'true');
+ expect(screen.getByTestId('loadingSkeleton')).toBeInTheDocument();
+ expect(screen.queryByTestId('spanFlyoutContent')).not.toBeInTheDocument();
+ });
+
+ it('should not render content when hit is null (even if not loading)', () => {
+ mockUseDocumentFlyoutData.mockReturnValue({
+ type: 'spanDetailFlyout',
+ hit: null,
+ loading: false,
+ title: 'Span document',
+ logDataView: null,
+ error: null,
+ });
+
+ render();
+
+ expect(screen.queryByTestId('spanFlyoutContent')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('log flyout edge cases', () => {
+ it('should render EuiCallOut when data has error', () => {
+ mockUseDocumentFlyoutData.mockReturnValue({
+ type: 'logsFlyout',
+ hit: mockLogHit,
+ loading: false,
+ title: 'Log document',
+ logDataView: dataViewMock,
+ error: 'Failed to load data view',
+ });
+
+ render();
+
+ expect(screen.getByText('Failed to load data view')).toBeInTheDocument();
+ expect(screen.getByTestId('logFlyoutContent')).toBeInTheDocument();
+ });
+
+ it('should not render LogFlyoutContent when logDataView is null', () => {
+ mockUseDocumentFlyoutData.mockReturnValue({
+ type: 'logsFlyout',
+ hit: mockLogHit,
+ loading: false,
+ title: 'Log document',
+ logDataView: null,
+ error: null,
+ });
+
+ render();
+
+ expect(screen.queryByTestId('logFlyoutContent')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('switching between types', () => {
+ it('should correctly switch from span to log type', () => {
+ mockUseDocumentFlyoutData
+ .mockReturnValueOnce({
+ type: 'spanDetailFlyout',
+ hit: mockSpanHit,
+ loading: false,
+ title: 'Span document',
+ logDataView: null,
+ error: null,
+ })
+ .mockReturnValueOnce({
+ type: 'logsFlyout',
+ hit: mockLogHit,
+ loading: false,
+ title: 'Log document',
+ logDataView: dataViewMock,
+ error: null,
+ });
+
+ const { rerender } = render();
+
+ expect(screen.getByTestId('spanFlyoutContent')).toBeInTheDocument();
+ expect(screen.queryByTestId('logFlyoutContent')).not.toBeInTheDocument();
+
+ rerender();
+
+ expect(screen.queryByTestId('spanFlyoutContent')).not.toBeInTheDocument();
+ expect(screen.getByTestId('logFlyoutContent')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/document_detail_flyout.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/document_detail_flyout.tsx
new file mode 100644
index 0000000000000..6d4341ab291aa
--- /dev/null
+++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/document_detail_flyout.tsx
@@ -0,0 +1,84 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { EuiCallOut } from '@elastic/eui';
+import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types';
+import React from 'react';
+import { WaterfallFlyout } from '.';
+import type { TraceOverviewSections } from '../../../doc_viewer_overview/overview';
+import { spanFlyoutId, SpanFlyoutContent } from './span_flyout';
+import { logsFlyoutId, LogFlyoutContent } from './logs_flyout';
+import {
+ useDocumentFlyoutData,
+ type DocumentType,
+ type DocumentFlyoutData,
+} from './use_document_flyout_data';
+
+export type { DocumentType } from './use_document_flyout_data';
+
+interface FlyoutContentProps {
+ data: DocumentFlyoutData;
+ dataView: DocViewRenderProps['dataView'];
+ activeSection?: TraceOverviewSections;
+}
+
+function FlyoutContent({ data, dataView, activeSection }: FlyoutContentProps) {
+ if (!data.hit) {
+ return null;
+ }
+
+ const isSpanType = data.type === spanFlyoutId;
+ if (isSpanType) {
+ return ;
+ }
+
+ const isLogType = data.type === logsFlyoutId;
+ if (isLogType && data.logDataView) {
+ return ;
+ }
+
+ return null;
+}
+
+export interface DocumentDetailFlyoutProps {
+ type: DocumentType;
+ docId: string;
+ docIndex?: string;
+ traceId: string;
+ dataView: DocViewRenderProps['dataView'];
+ onCloseFlyout: () => void;
+ activeSection?: TraceOverviewSections;
+}
+
+export function DocumentDetailFlyout({
+ type,
+ docId,
+ docIndex,
+ traceId,
+ dataView,
+ onCloseFlyout,
+ activeSection,
+}: DocumentDetailFlyoutProps) {
+ const data = useDocumentFlyoutData({ type, docId, traceId, docIndex });
+
+ return (
+
+ {data.error && }
+ {data.hit ? (
+
+ ) : null}
+
+ );
+}
diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/index.test.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/index.test.tsx
index 0da237b447a35..9cd3382e34ccb 100644
--- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/index.test.tsx
+++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/index.test.tsx
@@ -46,7 +46,6 @@ describe('WaterfallFlyout', () => {
const defaultProps: Props = {
title: 'Test Flyout Title',
- flyoutId: 'testFlyoutId',
onCloseFlyout: jest.fn(),
hit: mockHit,
loading: false,
diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/index.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/index.tsx
index 6766748325bbf..b5c5f6547100a 100644
--- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/index.tsx
+++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/index.tsx
@@ -71,7 +71,6 @@ const FlyoutTabs = ({ onClick, selectedTabId }: FlyoutTabsProps) => {
export interface Props {
title: string;
- flyoutId: string;
onCloseFlyout: () => void;
hit: DataTableRecord | null;
loading: boolean;
@@ -79,17 +78,10 @@ export interface Props {
children: React.ReactNode;
}
-export function WaterfallFlyout({
- onCloseFlyout,
- dataView,
- hit,
- loading,
- children,
- title,
- flyoutId,
-}: Props) {
+export function WaterfallFlyout({ onCloseFlyout, dataView, hit, loading, children, title }: Props) {
const [selectedTabId, setSelectedTabId] = useState(tabIds.OVERVIEW);
const flyoutTitleId = useGeneratedHtmlId();
+ const flyoutId = useGeneratedHtmlId({ prefix: 'documentDetailFlyout' });
return (
({
+ __esModule: true,
+ default: ({ hit, dataView, indexes, showTraceWaterfall }: any) => (
+
+ Logs Overview Mock
+
+ ),
+}));
+
+const mockIndexes = {
+ traces: 'traces-*',
+ logs: 'logs-*',
+};
+
+jest.mock('../../../../../../../hooks/use_data_sources', () => ({
+ useDataSourcesContext: () => ({
+ indexes: mockIndexes,
+ }),
+}));
+
+describe('LogFlyoutContent', () => {
+ const mockHit = buildDataTableRecord(
+ {
+ _id: 'test-log-id',
+ _index: 'logs-default',
+ _source: {
+ '@timestamp': '2023-01-01T00:00:00.000Z',
+ message: 'test log message',
+ },
+ },
+ dataViewMock
+ );
+
+ const defaultProps: LogFlyoutContentProps = {
+ hit: mockHit,
+ logDataView: dataViewMock,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render LogsOverview component with correct props', () => {
+ render();
+
+ const logsOverview = screen.getByTestId('logsOverviewComponent');
+ expect(logsOverview).toBeInTheDocument();
+ expect(logsOverview).toHaveAttribute('data-hit-id', mockHit.id);
+ expect(logsOverview).toHaveAttribute('data-show-trace-waterfall', 'false');
+ });
+
+ it('should render with different hit data', () => {
+ const differentHit = buildDataTableRecord(
+ {
+ _id: 'different-log-id',
+ _index: 'logs-other',
+ _source: {
+ '@timestamp': '2023-01-02T00:00:00.000Z',
+ message: 'different log message',
+ },
+ },
+ dataViewMock
+ );
+
+ render();
+
+ const logsOverview = screen.getByTestId('logsOverviewComponent');
+ expect(logsOverview).toHaveAttribute('data-hit-id', differentHit.id);
+ });
+
+ it('should pass dataView correctly to LogsOverview', () => {
+ const differentDataView = { ...dataViewMock, id: 'different-data-view' } as typeof dataViewMock;
+
+ render();
+
+ const logsOverview = screen.getByTestId('logsOverviewComponent');
+ expect(logsOverview).toHaveAttribute('data-view-id', 'different-data-view');
+ });
+});
diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/index.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/index.tsx
index 5c84707823fc4..d125fc2bb5655 100644
--- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/index.tsx
+++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/index.tsx
@@ -7,71 +7,26 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
-import { EuiCallOut } from '@elastic/eui';
import type { DataTableRecord } from '@kbn/discover-utils';
-import { i18n } from '@kbn/i18n';
-import { flattenObject } from '@kbn/object-utils';
import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types';
-import React, { useMemo } from 'react';
-import { WaterfallFlyout } from '..';
+import React from 'react';
import LogsOverview from '../../../../../../doc_viewer_logs_overview';
import { useDataSourcesContext } from '../../../../../../../hooks/use_data_sources';
-import { useAdhocDataView } from '../../hooks/use_adhoc_data_view';
-import { useFetchLog } from '../../hooks/use_fetch_log';
+
+export { useLogFlyoutData } from './use_log_flyout_data';
+export type { UseLogFlyoutDataParams, LogFlyoutData } from './use_log_flyout_data';
export const logsFlyoutId = 'logsFlyout' as const;
-export interface LogsFlyoutProps {
- onCloseFlyout: () => void;
- id: string;
- index?: string;
- dataView: DocViewRenderProps['dataView'];
+export interface LogFlyoutContentProps {
+ hit: DataTableRecord;
+ logDataView: DocViewRenderProps['dataView'];
}
-export function LogsFlyout({ onCloseFlyout, id, index, dataView }: LogsFlyoutProps) {
- const { loading, log, index: resolvedIndex } = useFetchLog({ id, index });
+export function LogFlyoutContent({ hit, logDataView }: LogFlyoutContentProps) {
const { indexes } = useDataSourcesContext();
- const {
- dataView: logDataView,
- error,
- loading: loadingDataView,
- } = useAdhocDataView({ index: resolvedIndex ?? null });
-
- const documentAsHit = useMemo(() => {
- if (!log || !id || !resolvedIndex) return null;
-
- return {
- id,
- raw: {
- _index: resolvedIndex,
- _id: id,
- _source: log,
- },
- flattened: flattenObject(log),
- };
- }, [id, log, resolvedIndex]);
return (
-
- {error ? : null}
- {documentAsHit && logDataView ? (
-
- ) : null}
-
+
);
}
diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/use_log_flyout_data.test.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/use_log_flyout_data.test.ts
new file mode 100644
index 0000000000000..97b0d7fac71ad
--- /dev/null
+++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/use_log_flyout_data.test.ts
@@ -0,0 +1,340 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { renderHook, waitFor } from '@testing-library/react';
+import { useLogFlyoutData } from './use_log_flyout_data';
+import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
+
+const mockUseFetchLog = jest.fn();
+const mockUseAdhocDataView = jest.fn();
+
+jest.mock('../../hooks/use_fetch_log', () => ({
+ useFetchLog: (params: { id: string; index?: string }) => mockUseFetchLog(params),
+}));
+
+jest.mock('../../hooks/use_adhoc_data_view', () => ({
+ useAdhocDataView: (params: { index: string | null }) => mockUseAdhocDataView(params),
+}));
+
+describe('useLogFlyoutData', () => {
+ const id = 'test-log-id';
+ const index = 'logs-*';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return loading true when fetching log', () => {
+ mockUseFetchLog.mockReturnValue({
+ loading: true,
+ log: undefined,
+ index: undefined,
+ });
+ mockUseAdhocDataView.mockReturnValue({
+ dataView: null,
+ error: null,
+ loading: false,
+ });
+
+ const { result } = renderHook(() => useLogFlyoutData({ id }));
+
+ expect(result.current.loading).toBe(true);
+ expect(result.current.hit).toBeNull();
+ expect(mockUseFetchLog).toHaveBeenCalledWith({ id });
+ });
+
+ it('should return loading true when fetching data view', () => {
+ mockUseFetchLog.mockReturnValue({
+ loading: false,
+ log: { message: 'test' },
+ index: 'logs-*',
+ });
+ mockUseAdhocDataView.mockReturnValue({
+ dataView: null,
+ error: null,
+ loading: true,
+ });
+
+ const { result } = renderHook(() => useLogFlyoutData({ id }));
+
+ expect(result.current.loading).toBe(true);
+ });
+
+ it('should return loading true when both are loading', () => {
+ mockUseFetchLog.mockReturnValue({
+ loading: true,
+ log: undefined,
+ index: undefined,
+ });
+ mockUseAdhocDataView.mockReturnValue({
+ dataView: null,
+ error: null,
+ loading: true,
+ });
+
+ const { result } = renderHook(() => useLogFlyoutData({ id }));
+
+ expect(result.current.loading).toBe(true);
+ });
+
+ it('should return null hit when log is not available', async () => {
+ mockUseFetchLog.mockReturnValue({
+ loading: false,
+ log: undefined,
+ index: undefined,
+ });
+ mockUseAdhocDataView.mockReturnValue({
+ dataView: null,
+ error: null,
+ loading: false,
+ });
+
+ const { result } = renderHook(() => useLogFlyoutData({ id }));
+
+ await waitFor(() => !result.current.loading);
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.hit).toBeNull();
+ });
+
+ it('should return null hit when id is empty', async () => {
+ mockUseFetchLog.mockReturnValue({
+ loading: false,
+ log: { message: 'test' },
+ index: 'logs-*',
+ });
+ mockUseAdhocDataView.mockReturnValue({
+ dataView: dataViewMock,
+ error: null,
+ loading: false,
+ });
+
+ const { result } = renderHook(() => useLogFlyoutData({ id: '' }));
+
+ await waitFor(() => !result.current.loading);
+
+ expect(result.current.hit).toBeNull();
+ });
+
+ it('should return null hit when resolvedIndex is not available', async () => {
+ mockUseFetchLog.mockReturnValue({
+ loading: false,
+ log: { message: 'test' },
+ index: undefined,
+ });
+ mockUseAdhocDataView.mockReturnValue({
+ dataView: null,
+ error: null,
+ loading: false,
+ });
+
+ const { result } = renderHook(() => useLogFlyoutData({ id }));
+
+ await waitFor(() => !result.current.loading);
+
+ expect(result.current.hit).toBeNull();
+ });
+
+ it('should return hit with correct structure when log is fetched', async () => {
+ const mockLog = {
+ message: 'test log message',
+ '@timestamp': '2023-01-01T00:00:00.000Z',
+ };
+ const resolvedIndex = 'logs-2023.01.01';
+
+ mockUseFetchLog.mockReturnValue({
+ loading: false,
+ log: mockLog,
+ index: resolvedIndex,
+ });
+ mockUseAdhocDataView.mockReturnValue({
+ dataView: dataViewMock,
+ error: null,
+ loading: false,
+ });
+
+ const { result } = renderHook(() => useLogFlyoutData({ id }));
+
+ await waitFor(() => !result.current.loading);
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.hit).not.toBeNull();
+ expect(result.current.hit?.id).toBe(id);
+ expect(result.current.hit?.raw._index).toBe(resolvedIndex);
+ expect(result.current.hit?.raw._id).toBe(id);
+ expect(result.current.hit?.raw._source).toBe(mockLog);
+ expect(result.current.hit?.flattened).toBeDefined();
+ });
+
+ it('should return "Log document" title', async () => {
+ mockUseFetchLog.mockReturnValue({
+ loading: false,
+ log: { message: 'test' },
+ index: 'logs-*',
+ });
+ mockUseAdhocDataView.mockReturnValue({
+ dataView: dataViewMock,
+ error: null,
+ loading: false,
+ });
+
+ const { result } = renderHook(() => useLogFlyoutData({ id }));
+
+ await waitFor(() => !result.current.loading);
+
+ expect(result.current.title).toBe('Log document');
+ });
+
+ it('should return error from adhoc data view', async () => {
+ const errorMessage = 'Failed to create data view';
+
+ mockUseFetchLog.mockReturnValue({
+ loading: false,
+ log: { message: 'test' },
+ index: 'logs-*',
+ });
+ mockUseAdhocDataView.mockReturnValue({
+ dataView: null,
+ error: errorMessage,
+ loading: false,
+ });
+
+ const { result } = renderHook(() => useLogFlyoutData({ id }));
+
+ await waitFor(() => !result.current.loading);
+
+ expect(result.current.error).toBe(errorMessage);
+ });
+
+ it('should return logDataView from adhoc data view', async () => {
+ mockUseFetchLog.mockReturnValue({
+ loading: false,
+ log: { message: 'test' },
+ index: 'logs-*',
+ });
+ mockUseAdhocDataView.mockReturnValue({
+ dataView: dataViewMock,
+ error: null,
+ loading: false,
+ });
+
+ const { result } = renderHook(() => useLogFlyoutData({ id }));
+
+ await waitFor(() => !result.current.loading);
+
+ expect(result.current.logDataView).toBe(dataViewMock);
+ });
+
+ it('should pass index to useFetchLog when provided', () => {
+ mockUseFetchLog.mockReturnValue({
+ loading: true,
+ log: undefined,
+ index: undefined,
+ });
+ mockUseAdhocDataView.mockReturnValue({
+ dataView: null,
+ error: null,
+ loading: false,
+ });
+
+ renderHook(() => useLogFlyoutData({ id, index }));
+
+ expect(mockUseFetchLog).toHaveBeenCalledWith({ id, index });
+ });
+
+ it('should pass resolvedIndex to useAdhocDataView', () => {
+ const resolvedIndex = 'logs-2023.01.01';
+
+ mockUseFetchLog.mockReturnValue({
+ loading: false,
+ log: { message: 'test' },
+ index: resolvedIndex,
+ });
+ mockUseAdhocDataView.mockReturnValue({
+ dataView: dataViewMock,
+ error: null,
+ loading: false,
+ });
+
+ renderHook(() => useLogFlyoutData({ id }));
+
+ expect(mockUseAdhocDataView).toHaveBeenCalledWith({ index: resolvedIndex });
+ });
+
+ it('should pass null to useAdhocDataView when resolvedIndex is undefined', () => {
+ mockUseFetchLog.mockReturnValue({
+ loading: false,
+ log: undefined,
+ index: undefined,
+ });
+ mockUseAdhocDataView.mockReturnValue({
+ dataView: null,
+ error: null,
+ loading: false,
+ });
+
+ renderHook(() => useLogFlyoutData({ id }));
+
+ expect(mockUseAdhocDataView).toHaveBeenCalledWith({ index: null });
+ });
+
+ it('should refetch when id changes', async () => {
+ const mockLog1 = { message: 'log 1' };
+ const mockLog2 = { message: 'log 2' };
+
+ mockUseFetchLog
+ .mockReturnValueOnce({ loading: false, log: mockLog1, index: 'logs-*' })
+ .mockReturnValueOnce({ loading: false, log: mockLog2, index: 'logs-*' });
+ mockUseAdhocDataView.mockReturnValue({
+ dataView: dataViewMock,
+ error: null,
+ loading: false,
+ });
+
+ const { result, rerender } = renderHook(
+ ({ logId }: { logId: string }) => useLogFlyoutData({ id: logId }),
+ {
+ initialProps: { logId: 'log-1' },
+ }
+ );
+
+ await waitFor(() => !result.current.loading);
+ expect(result.current.hit?.raw._source).toBe(mockLog1);
+ expect(mockUseFetchLog).toHaveBeenCalledWith({ id: 'log-1' });
+
+ rerender({ logId: 'log-2' });
+
+ expect(mockUseFetchLog).toHaveBeenCalledWith({ id: 'log-2' });
+ });
+
+ it('should memoize hit when log does not change', async () => {
+ const mockLog = { message: 'test' };
+ const resolvedIndex = 'logs-*';
+
+ mockUseFetchLog.mockReturnValue({
+ loading: false,
+ log: mockLog,
+ index: resolvedIndex,
+ });
+ mockUseAdhocDataView.mockReturnValue({
+ dataView: dataViewMock,
+ error: null,
+ loading: false,
+ });
+
+ const { result, rerender } = renderHook(() => useLogFlyoutData({ id }));
+
+ await waitFor(() => !result.current.loading);
+ const firstHit = result.current.hit;
+
+ rerender();
+
+ expect(result.current.hit).toBe(firstHit);
+ });
+});
diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/use_log_flyout_data.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/use_log_flyout_data.ts
new file mode 100644
index 0000000000000..0f766494a60bf
--- /dev/null
+++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/use_log_flyout_data.ts
@@ -0,0 +1,62 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import type { DataTableRecord } from '@kbn/discover-utils';
+import { i18n } from '@kbn/i18n';
+import { flattenObject } from '@kbn/object-utils';
+import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types';
+import { useMemo } from 'react';
+import { useAdhocDataView } from '../../hooks/use_adhoc_data_view';
+import { useFetchLog } from '../../hooks/use_fetch_log';
+import type { BaseFlyoutData } from '../use_document_flyout_data';
+
+export interface UseLogFlyoutDataParams {
+ id: string;
+ index?: string;
+}
+
+export interface LogFlyoutData extends BaseFlyoutData {
+ logDataView: DocViewRenderProps['dataView'] | null;
+}
+
+export function useLogFlyoutData({ id, index }: UseLogFlyoutDataParams): LogFlyoutData {
+ const { loading, log, index: resolvedIndex } = useFetchLog({ id, index });
+ const {
+ dataView: logDataView,
+ error,
+ loading: loadingDataView,
+ } = useAdhocDataView({ index: resolvedIndex ?? null });
+
+ const hit = useMemo(() => {
+ if (!log || !id || !resolvedIndex) return null;
+
+ return {
+ id,
+ raw: {
+ _index: resolvedIndex,
+ _id: id,
+ _source: log,
+ },
+ flattened: flattenObject(log),
+ };
+ }, [id, log, resolvedIndex]);
+
+ const title = i18n.translate(
+ 'unifiedDocViewer.observability.traces.fullScreenWaterfall.logFlyout.title.log',
+ { defaultMessage: 'Log document' }
+ );
+
+ return {
+ hit,
+ loading: loading || loadingDataView,
+ title,
+ error,
+ logDataView,
+ };
+}
diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.test.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.test.tsx
new file mode 100644
index 0000000000000..c5b7171e58060
--- /dev/null
+++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.test.tsx
@@ -0,0 +1,125 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import { SpanFlyoutContent, type SpanFlyoutContentProps } from '.';
+import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
+import { buildDataTableRecord } from '@kbn/discover-utils';
+
+const mockOpenAndScrollToSection = jest.fn();
+
+jest.mock('../../../../doc_viewer_overview/overview', () => {
+ const ReactMock = jest.requireActual('react');
+
+ const stableApi = {
+ openAndScrollToSection: (section: string) => mockOpenAndScrollToSection(section),
+ };
+
+ return {
+ Overview: ReactMock.forwardRef(
+ ({ hit, indexes, showWaterfall, showActions, dataView }: any, ref: any) => {
+ ReactMock.useImperativeHandle(ref, () => stableApi, []);
+
+ return (
+
+ Overview Mock
+
+ );
+ }
+ ),
+ };
+});
+
+const mockIndexes = {
+ traces: 'traces-*',
+ logs: 'logs-*',
+};
+
+jest.mock('../../../../../../../hooks/use_data_sources', () => ({
+ useDataSourcesContext: () => ({
+ indexes: mockIndexes,
+ }),
+}));
+
+describe('SpanFlyoutContent', () => {
+ const mockHit = buildDataTableRecord(
+ {
+ _id: 'test-span-id',
+ _index: 'traces-apm-default',
+ _source: {
+ '@timestamp': '2023-01-01T00:00:00.000Z',
+ 'span.name': 'test-span',
+ 'span.id': 'test-span-id',
+ 'trace.id': 'test-trace-id',
+ },
+ },
+ dataViewMock
+ );
+
+ const defaultProps: SpanFlyoutContentProps = {
+ hit: mockHit,
+ dataView: dataViewMock,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockOpenAndScrollToSection.mockClear();
+ });
+
+ it('should render Overview component with correct props', () => {
+ render();
+
+ const overview = screen.getByTestId('overviewComponent');
+ expect(overview).toBeInTheDocument();
+ expect(overview).toHaveAttribute('data-hit-id', mockHit.id);
+ expect(overview).toHaveAttribute('data-show-waterfall', 'false');
+ expect(overview).toHaveAttribute('data-show-actions', 'false');
+ });
+
+ it('should render with different hit data', () => {
+ const differentHit = buildDataTableRecord(
+ {
+ _id: 'different-span-id',
+ _index: 'traces-apm-default',
+ _source: {
+ '@timestamp': '2023-01-02T00:00:00.000Z',
+ 'span.name': 'different-span',
+ },
+ },
+ dataViewMock
+ );
+
+ render();
+
+ const overview = screen.getByTestId('overviewComponent');
+ expect(overview).toHaveAttribute('data-hit-id', differentHit.id);
+ });
+
+ describe('activeSection behavior', () => {
+ it('should call openAndScrollToSection when activeSection is provided', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mockOpenAndScrollToSection).toHaveBeenCalledWith('errors-table');
+ });
+ });
+
+ it('should not call openAndScrollToSection when activeSection is undefined', () => {
+ render();
+
+ expect(mockOpenAndScrollToSection).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.tsx
index d7f35d966090b..2900bd2740058 100644
--- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.tsx
+++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.tsx
@@ -8,54 +8,27 @@
*/
import type { DataTableRecord } from '@kbn/discover-utils';
-import { i18n } from '@kbn/i18n';
-import { flattenObject } from '@kbn/object-utils';
import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types';
-import React, { useEffect, useMemo, useState } from 'react';
-import { WaterfallFlyout } from '..';
+import React, { useEffect, useState } from 'react';
import type { OverviewApi } from '../../../../doc_viewer_overview/overview';
import { Overview, type TraceOverviewSections } from '../../../../doc_viewer_overview/overview';
import { useDataSourcesContext } from '../../../../../../../hooks/use_data_sources';
-import { isSpanHit } from '../../helpers/is_span';
-import { useFetchSpan } from '../../hooks/use_fetch_span';
+
+export { useSpanFlyoutData } from './use_span_flyout_data';
+export type { UseSpanFlyoutDataParams, SpanFlyoutData } from './use_span_flyout_data';
export const spanFlyoutId = 'spanDetailFlyout' as const;
-export interface SpanFlyoutProps {
- spanId: string;
- traceId: string;
+export interface SpanFlyoutContentProps {
+ hit: DataTableRecord;
dataView: DocViewRenderProps['dataView'];
- onCloseFlyout: () => void;
activeSection?: TraceOverviewSections;
}
-export const SpanFlyout = ({
- spanId,
- traceId,
- dataView,
- onCloseFlyout,
- activeSection,
-}: SpanFlyoutProps) => {
- const { span, loading } = useFetchSpan({ spanId, traceId });
+export function SpanFlyoutContent({ hit, dataView, activeSection }: SpanFlyoutContentProps) {
const { indexes } = useDataSourcesContext();
const [flyoutRef, setFlyoutRef] = useState(null);
- const documentAsHit = useMemo(() => {
- if (!span) return null;
-
- return {
- id: span._id,
- raw: {
- _index: span._index,
- _id: span._id,
- _source: span as unknown as Record,
- },
- flattened: flattenObject(span),
- };
- }, [span]);
-
- const isSpan = isSpanHit(documentAsHit);
-
useEffect(() => {
if (activeSection && flyoutRef) {
flyoutRef.openAndScrollToSection(activeSection);
@@ -63,40 +36,13 @@ export const SpanFlyout = ({
}, [activeSection, flyoutRef]);
return (
-
- {documentAsHit ? (
-
- ) : null}
-
+ />
);
-};
+}
diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/use_span_flyout_data.test.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/use_span_flyout_data.test.ts
new file mode 100644
index 0000000000000..940129a5768ab
--- /dev/null
+++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/use_span_flyout_data.test.ts
@@ -0,0 +1,243 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { renderHook, waitFor } from '@testing-library/react';
+import type { UnifiedSpanDocument } from '@kbn/apm-types';
+import { useSpanFlyoutData } from './use_span_flyout_data';
+
+const mockUseFetchSpan = jest.fn();
+
+jest.mock('../../hooks/use_fetch_span', () => ({
+ useFetchSpan: (params: { spanId: string; traceId: string }) => mockUseFetchSpan(params),
+}));
+
+jest.mock('../../helpers/is_span', () => ({
+ isSpanHit: jest.fn((hit) => {
+ if (!hit) return false;
+ return hit.flattened?.['span.id'] !== undefined;
+ }),
+}));
+
+describe('useSpanFlyoutData', () => {
+ const spanId = 'test-span-id';
+ const traceId = 'test-trace-id';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return loading true when fetching span', () => {
+ mockUseFetchSpan.mockReturnValue({
+ span: undefined,
+ loading: true,
+ error: undefined,
+ });
+
+ const { result } = renderHook(() => useSpanFlyoutData({ spanId, traceId }));
+
+ expect(result.current.loading).toBe(true);
+ expect(result.current.hit).toBeNull();
+ expect(result.current.error).toBeNull();
+ expect(mockUseFetchSpan).toHaveBeenCalledWith({ spanId, traceId });
+ });
+
+ it('should return null hit when span is not available', async () => {
+ mockUseFetchSpan.mockReturnValue({
+ span: undefined,
+ loading: false,
+ error: undefined,
+ });
+
+ const { result } = renderHook(() => useSpanFlyoutData({ spanId, traceId }));
+
+ await waitFor(() => !result.current.loading);
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.hit).toBeNull();
+ });
+
+ it('should return hit with correct structure when span is fetched', async () => {
+ const mockSpan: UnifiedSpanDocument = {
+ _id: 'test-id',
+ _index: 'traces-apm-*',
+ span: {
+ id: spanId,
+ name: 'test-span',
+ duration: { us: 100000 },
+ },
+ trace: { id: traceId },
+ service: { name: 'test-service' },
+ } as UnifiedSpanDocument;
+
+ mockUseFetchSpan.mockReturnValue({
+ span: mockSpan,
+ loading: false,
+ error: undefined,
+ });
+
+ const { result } = renderHook(() => useSpanFlyoutData({ spanId, traceId }));
+
+ await waitFor(() => !result.current.loading);
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.hit).not.toBeNull();
+ expect(result.current.hit?.id).toBe('test-id');
+ expect(result.current.hit?.raw._index).toBe('traces-apm-*');
+ expect(result.current.hit?.raw._id).toBe('test-id');
+ expect(result.current.hit?.raw._source).toBe(mockSpan);
+ expect(result.current.hit?.flattened).toBeDefined();
+ expect(result.current.error).toBeNull();
+ });
+
+ it('should return "Span document" title when hit is a span', async () => {
+ const mockSpan: UnifiedSpanDocument = {
+ _id: 'test-id',
+ _index: 'traces-apm-*',
+ span: {
+ id: spanId,
+ name: 'test-span',
+ },
+ } as UnifiedSpanDocument;
+
+ mockUseFetchSpan.mockReturnValue({
+ span: mockSpan,
+ loading: false,
+ error: undefined,
+ });
+
+ const isSpanHitMock = jest.requireMock('../../helpers/is_span').isSpanHit;
+ isSpanHitMock.mockReturnValue(true);
+
+ const { result } = renderHook(() => useSpanFlyoutData({ spanId, traceId }));
+
+ await waitFor(() => !result.current.loading);
+
+ expect(result.current.title).toBe('Span document');
+ });
+
+ it('should return "Transaction document" title when hit is a transaction', async () => {
+ const mockTransaction: UnifiedSpanDocument = {
+ _id: 'test-id',
+ _index: 'traces-apm-*',
+ transaction: {
+ id: 'transaction-id',
+ name: 'test-transaction',
+ },
+ } as UnifiedSpanDocument;
+
+ mockUseFetchSpan.mockReturnValue({
+ span: mockTransaction,
+ loading: false,
+ error: undefined,
+ });
+
+ const isSpanHitMock = jest.requireMock('../../helpers/is_span').isSpanHit;
+ isSpanHitMock.mockReturnValue(false);
+
+ const { result } = renderHook(() => useSpanFlyoutData({ spanId, traceId }));
+
+ await waitFor(() => !result.current.loading);
+
+ expect(result.current.title).toBe('Transaction document');
+ });
+
+ it('should refetch when spanId changes', async () => {
+ const mockSpan1: UnifiedSpanDocument = {
+ _id: 'span-1',
+ _index: 'traces-apm-*',
+ span: { id: 'span-1', name: 'span-1' },
+ } as UnifiedSpanDocument;
+
+ const mockSpan2: UnifiedSpanDocument = {
+ _id: 'span-2',
+ _index: 'traces-apm-*',
+ span: { id: 'span-2', name: 'span-2' },
+ } as UnifiedSpanDocument;
+
+ mockUseFetchSpan
+ .mockReturnValueOnce({ span: mockSpan1, loading: false, error: undefined })
+ .mockReturnValueOnce({ span: mockSpan2, loading: false, error: undefined });
+
+ const { result, rerender } = renderHook(
+ ({ sId, tId }: { sId: string; tId: string }) =>
+ useSpanFlyoutData({ spanId: sId, traceId: tId }),
+ {
+ initialProps: { sId: 'span-1', tId: traceId },
+ }
+ );
+
+ await waitFor(() => !result.current.loading);
+ expect(result.current.hit?.id).toBe('span-1');
+
+ rerender({ sId: 'span-2', tId: traceId });
+
+ await waitFor(() => result.current.hit?.id === 'span-2');
+ expect(result.current.hit?.id).toBe('span-2');
+ expect(mockUseFetchSpan).toHaveBeenCalledWith({ spanId: 'span-2', traceId });
+ });
+
+ it('should refetch when traceId changes', async () => {
+ const mockSpan: UnifiedSpanDocument = {
+ _id: 'test-id',
+ _index: 'traces-apm-*',
+ span: { id: spanId, name: 'test-span' },
+ } as UnifiedSpanDocument;
+
+ mockUseFetchSpan.mockReturnValue({ span: mockSpan, loading: false, error: undefined });
+
+ const { rerender } = renderHook(
+ ({ sId, tId }: { sId: string; tId: string }) =>
+ useSpanFlyoutData({ spanId: sId, traceId: tId }),
+ {
+ initialProps: { sId: spanId, tId: 'trace-1' },
+ }
+ );
+
+ expect(mockUseFetchSpan).toHaveBeenCalledWith({ spanId, traceId: 'trace-1' });
+
+ rerender({ sId: spanId, tId: 'trace-2' });
+
+ expect(mockUseFetchSpan).toHaveBeenCalledWith({ spanId, traceId: 'trace-2' });
+ });
+
+ it('should memoize hit when span does not change', async () => {
+ const mockSpan: UnifiedSpanDocument = {
+ _id: 'test-id',
+ _index: 'traces-apm-*',
+ span: { id: spanId, name: 'test-span' },
+ } as UnifiedSpanDocument;
+
+ mockUseFetchSpan.mockReturnValue({ span: mockSpan, loading: false, error: undefined });
+
+ const { result, rerender } = renderHook(() => useSpanFlyoutData({ spanId, traceId }));
+
+ await waitFor(() => !result.current.loading);
+ const firstHit = result.current.hit;
+
+ rerender();
+
+ expect(result.current.hit).toBe(firstHit);
+ });
+
+ it('should return error message when fetch fails', async () => {
+ const errorMessage = 'Failed to fetch span';
+ mockUseFetchSpan.mockReturnValue({
+ span: undefined,
+ loading: false,
+ error: new Error(errorMessage),
+ });
+
+ const { result } = renderHook(() => useSpanFlyoutData({ spanId, traceId }));
+
+ await waitFor(() => !result.current.loading);
+
+ expect(result.current.error).toBe(errorMessage);
+ expect(result.current.hit).toBeNull();
+ });
+});
diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/use_span_flyout_data.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/use_span_flyout_data.ts
new file mode 100644
index 0000000000000..081956ba26f42
--- /dev/null
+++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/use_span_flyout_data.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import type { DataTableRecord } from '@kbn/discover-utils';
+import { i18n } from '@kbn/i18n';
+import { flattenObject } from '@kbn/object-utils';
+import { useMemo } from 'react';
+import { isSpanHit } from '../../helpers/is_span';
+import { useFetchSpan } from '../../hooks/use_fetch_span';
+import type { BaseFlyoutData } from '../use_document_flyout_data';
+
+export interface UseSpanFlyoutDataParams {
+ spanId: string;
+ traceId: string;
+}
+
+export type SpanFlyoutData = BaseFlyoutData;
+
+export function useSpanFlyoutData({ spanId, traceId }: UseSpanFlyoutDataParams): SpanFlyoutData {
+ const { span, loading, error } = useFetchSpan({ spanId, traceId });
+
+ const hit = useMemo(() => {
+ if (!span) return null;
+
+ return {
+ id: span._id,
+ raw: {
+ _index: span._index,
+ _id: span._id,
+ _source: span as unknown as Record,
+ },
+ flattened: flattenObject(span),
+ };
+ }, [span]);
+
+ const isSpan = isSpanHit(hit);
+
+ const title = i18n.translate(
+ 'unifiedDocViewer.observability.traces.fullScreenWaterfall.spanFlyout.title',
+ {
+ defaultMessage: '{docType} document',
+ values: {
+ docType: isSpan
+ ? i18n.translate(
+ 'unifiedDocViewer.observability.traces.fullScreenWaterfall.spanFlyout.title.span',
+ { defaultMessage: 'Span' }
+ )
+ : i18n.translate(
+ 'unifiedDocViewer.observability.traces.fullScreenWaterfall.spanFlyout.title.transction',
+ { defaultMessage: 'Transaction' }
+ ),
+ },
+ }
+ );
+
+ return { hit, loading, title, error: error?.message ?? null };
+}
diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/use_document_flyout_data.test.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/use_document_flyout_data.test.ts
new file mode 100644
index 0000000000000..869850f331307
--- /dev/null
+++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/use_document_flyout_data.test.ts
@@ -0,0 +1,234 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { renderHook } from '@testing-library/react';
+import { useDocumentFlyoutData, type DocumentType } from './use_document_flyout_data';
+import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
+import { buildDataTableRecord } from '@kbn/discover-utils';
+
+const mockSpanHit = buildDataTableRecord(
+ {
+ _id: 'span-id',
+ _index: 'traces-apm-*',
+ _source: { 'span.id': 'span-id', 'span.name': 'test-span' },
+ },
+ dataViewMock
+);
+
+const mockLogHit = buildDataTableRecord(
+ {
+ _id: 'log-id',
+ _index: 'logs-*',
+ _source: { message: 'test log' },
+ },
+ dataViewMock
+);
+
+const mockUseSpanFlyoutData = jest.fn();
+const mockUseLogFlyoutData = jest.fn();
+
+jest.mock('./span_flyout', () => ({
+ spanFlyoutId: 'spanDetailFlyout',
+ useSpanFlyoutData: (params: any) => mockUseSpanFlyoutData(params),
+}));
+
+jest.mock('./logs_flyout', () => ({
+ logsFlyoutId: 'logsFlyout',
+ useLogFlyoutData: (params: any) => mockUseLogFlyoutData(params),
+}));
+
+describe('useDocumentFlyoutData', () => {
+ const traceId = 'test-trace-id';
+ const docId = 'test-doc-id';
+ const docIndex = 'logs-*';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUseSpanFlyoutData.mockReturnValue({
+ hit: null,
+ loading: false,
+ title: 'Span document',
+ error: null,
+ });
+
+ mockUseLogFlyoutData.mockReturnValue({
+ hit: null,
+ loading: false,
+ title: 'Log document',
+ logDataView: null,
+ error: null,
+ });
+ });
+
+ describe('span type', () => {
+ it('should call useSpanFlyoutData with docId and useLogFlyoutData with empty id', () => {
+ renderHook(() => useDocumentFlyoutData({ type: 'spanDetailFlyout', docId, traceId }));
+
+ expect(mockUseSpanFlyoutData).toHaveBeenCalledWith({ spanId: docId, traceId });
+ expect(mockUseLogFlyoutData).toHaveBeenCalledWith({ id: '', index: undefined });
+ });
+
+ it('should return span data when type is span', () => {
+ mockUseSpanFlyoutData.mockReturnValue({
+ hit: mockSpanHit,
+ loading: false,
+ title: 'Span document',
+ error: null,
+ });
+
+ const { result } = renderHook(() =>
+ useDocumentFlyoutData({ type: 'spanDetailFlyout', docId, traceId })
+ );
+
+ expect(result.current.type).toBe('spanDetailFlyout');
+ expect(result.current.hit).toBe(mockSpanHit);
+ expect(result.current.title).toBe('Span document');
+ });
+
+ it('should return loading true when span is loading', () => {
+ mockUseSpanFlyoutData.mockReturnValue({
+ hit: null,
+ loading: true,
+ title: 'Span document',
+ error: null,
+ });
+
+ const { result } = renderHook(() =>
+ useDocumentFlyoutData({ type: 'spanDetailFlyout', docId, traceId })
+ );
+
+ expect(result.current.loading).toBe(true);
+ });
+
+ it('should not include log-specific fields for span type', () => {
+ mockUseSpanFlyoutData.mockReturnValue({
+ hit: mockSpanHit,
+ loading: false,
+ title: 'Span document',
+ error: null,
+ });
+
+ const { result } = renderHook(() =>
+ useDocumentFlyoutData({ type: 'spanDetailFlyout', docId, traceId })
+ );
+
+ expect(result.current).not.toHaveProperty('logDataView');
+ });
+
+ it('should return error from span data', () => {
+ const errorMessage = 'Failed to fetch span';
+ mockUseSpanFlyoutData.mockReturnValue({
+ hit: null,
+ loading: false,
+ title: 'Span document',
+ error: errorMessage,
+ });
+
+ const { result } = renderHook(() =>
+ useDocumentFlyoutData({ type: 'spanDetailFlyout', docId, traceId })
+ );
+
+ expect(result.current.error).toBe(errorMessage);
+ });
+ });
+
+ describe('log type', () => {
+ it('should call useLogFlyoutData with docId and useSpanFlyoutData with empty spanId', () => {
+ renderHook(() => useDocumentFlyoutData({ type: 'logsFlyout', docId, traceId, docIndex }));
+
+ expect(mockUseSpanFlyoutData).toHaveBeenCalledWith({ spanId: '', traceId });
+ expect(mockUseLogFlyoutData).toHaveBeenCalledWith({ id: docId, index: docIndex });
+ });
+
+ it('should return log data when type is log', () => {
+ mockUseLogFlyoutData.mockReturnValue({
+ hit: mockLogHit,
+ loading: false,
+ title: 'Log document',
+ logDataView: dataViewMock,
+ error: null,
+ });
+
+ const { result } = renderHook(() =>
+ useDocumentFlyoutData({ type: 'logsFlyout', docId, traceId })
+ );
+
+ expect(result.current.type).toBe('logsFlyout');
+ expect(result.current.hit).toBe(mockLogHit);
+ expect(result.current.title).toBe('Log document');
+ expect(result.current.logDataView).toBe(dataViewMock);
+ });
+
+ it('should return loading true when log is loading', () => {
+ mockUseLogFlyoutData.mockReturnValue({
+ hit: null,
+ loading: true,
+ title: 'Log document',
+ logDataView: null,
+ error: null,
+ });
+
+ const { result } = renderHook(() =>
+ useDocumentFlyoutData({ type: 'logsFlyout', docId, traceId })
+ );
+
+ expect(result.current.loading).toBe(true);
+ });
+
+ it('should return error from log data', () => {
+ const errorMessage = 'Failed to create data view';
+ mockUseLogFlyoutData.mockReturnValue({
+ hit: mockLogHit,
+ loading: false,
+ title: 'Log document',
+ logDataView: null,
+ error: errorMessage,
+ });
+
+ const { result } = renderHook(() =>
+ useDocumentFlyoutData({ type: 'logsFlyout', docId, traceId })
+ );
+
+ expect(result.current.error).toBe(errorMessage);
+ });
+ });
+
+ describe('type switching', () => {
+ it('should update data when type changes', () => {
+ mockUseSpanFlyoutData.mockReturnValue({
+ hit: mockSpanHit,
+ loading: false,
+ title: 'Span document',
+ error: null,
+ });
+
+ mockUseLogFlyoutData.mockReturnValue({
+ hit: mockLogHit,
+ loading: false,
+ title: 'Log document',
+ logDataView: dataViewMock,
+ error: null,
+ });
+
+ const { result, rerender } = renderHook(
+ ({ type }: { type: DocumentType }) => useDocumentFlyoutData({ type, docId, traceId }),
+ { initialProps: { type: 'spanDetailFlyout' as DocumentType } }
+ );
+
+ expect(result.current.type).toBe('spanDetailFlyout');
+ expect(result.current.hit).toBe(mockSpanHit);
+
+ rerender({ type: 'logsFlyout' });
+
+ expect(result.current.type).toBe('logsFlyout');
+ expect(result.current.hit).toBe(mockLogHit);
+ });
+ });
+});
diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/use_document_flyout_data.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/use_document_flyout_data.ts
new file mode 100644
index 0000000000000..08b3e799dfcf5
--- /dev/null
+++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/use_document_flyout_data.ts
@@ -0,0 +1,82 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import type { DataTableRecord } from '@kbn/discover-utils';
+import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types';
+import { spanFlyoutId, useSpanFlyoutData } from './span_flyout';
+import { useLogFlyoutData } from './logs_flyout';
+
+export type DocumentType = 'spanDetailFlyout' | 'logsFlyout';
+
+export interface UseDocumentFlyoutDataParams {
+ type: DocumentType;
+ docId: string;
+ traceId: string;
+ docIndex?: string;
+}
+
+/**
+ * Base interface that all flyout data hooks must implement.
+ * Any new flyout type should extend this interface.
+ */
+export interface BaseFlyoutData {
+ hit: DataTableRecord | null;
+ loading: boolean;
+ title: string;
+ error: string | null;
+}
+
+export interface DocumentFlyoutData extends BaseFlyoutData {
+ type: DocumentType;
+ // Log-specific fields (only present when type is 'logsFlyout')
+ logDataView?: DocViewRenderProps['dataView'] | null;
+}
+
+/**
+ * Unified hook for fetching document flyout data.
+ * Orchestrates the individual hooks based on document type.
+ * Both hooks are called but short-circuit with empty params when not needed.
+ */
+export function useDocumentFlyoutData({
+ type,
+ docId,
+ traceId,
+ docIndex,
+}: UseDocumentFlyoutDataParams): DocumentFlyoutData {
+ const isSpanType = type === spanFlyoutId;
+
+ const spanData = useSpanFlyoutData({
+ spanId: isSpanType ? docId : '',
+ traceId,
+ });
+
+ const logData = useLogFlyoutData({
+ id: isSpanType ? '' : docId,
+ index: docIndex,
+ });
+
+ if (isSpanType) {
+ return {
+ type,
+ hit: spanData.hit,
+ loading: spanData.loading,
+ title: spanData.title,
+ error: spanData.error,
+ };
+ }
+
+ return {
+ type,
+ hit: logData.hit,
+ loading: logData.loading,
+ title: logData.title,
+ logDataView: logData.logDataView,
+ error: logData.error,
+ };
+}