[APM] Trace explorer (#131897)

* [APM] Trace explorer

* Make sure tabs work on trace explorer

* Add links to trace explorer from error detail view & service map

* Add API tests

* Fix failing E2E test

* don't select edges when explorer is disabled

* Fix lint error

* Update x-pack/plugins/observability/server/ui_settings.ts

Co-authored-by: Søren Louv-Jansen <sorenlouv@gmail.com>

* Review feedback

* Rename const in API tests

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Søren Louv-Jansen <sorenlouv@gmail.com>
This commit is contained in:
Dario Gieselaar 2022-05-23 15:27:09 +02:00 committed by GitHub
parent 3e8e89069f
commit c16bcdc15d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 2478 additions and 406 deletions

View file

@ -58,6 +58,8 @@ export interface EuiCodeEditorProps extends SupportedAriaAttributes, Omit<IAceEd
*/
mode?: IAceEditorProps['mode'] | object;
id?: string;
onAceEditorRef?: (editor: AceEditor | null) => void;
}
export interface EuiCodeEditorState {
@ -98,6 +100,7 @@ class EuiCodeEditor extends Component<EuiCodeEditorProps, EuiCodeEditorState> {
setOrRemoveAttribute(textbox, 'aria-labelledby', this.props['aria-labelledby']);
setOrRemoveAttribute(textbox, 'aria-describedby', this.props['aria-describedby']);
}
this.props.onAceEditorRef?.(aceEditor);
};
onEscToExit = () => {

View file

@ -5,4 +5,5 @@
* 2.0.
*/
export const APM_STATIC_INDEX_PATTERN_ID = 'apm_static_index_pattern_id';
// value of const needs to be backwards compatible
export const APM_STATIC_DATA_VIEW_ID = 'apm_static_index_pattern_id';

View file

@ -0,0 +1,16 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export interface TraceSearchQuery {
query: string;
type: TraceSearchType;
}
export enum TraceSearchType {
kql = 'kql',
eql = 'eql',
}

View file

@ -89,7 +89,7 @@ describe('Error details', () => {
describe('when clicking on View x occurences in discover', () => {
it('should redirects the user to discover', () => {
cy.visit(errorDetailsPageHref);
cy.contains('View 1 occurrence in Discover.').click();
cy.contains('View 1 occurrence in Discover').click();
cy.url().should('include', 'app/discover');
});
});

View file

@ -17,7 +17,8 @@
"observability",
"ruleRegistry",
"triggersActionsUi",
"unifiedSearch"
"unifiedSearch",
"dataViews"
],
"optionalPlugins": [
"actions",
@ -33,12 +34,16 @@
],
"server": true,
"ui": true,
"configPath": ["xpack", "apm"],
"configPath": [
"xpack",
"apm"
],
"requiredBundles": [
"fleet",
"kibanaReact",
"kibanaUtils",
"ml",
"observability"
"observability",
"esUiShared"
]
}

View file

@ -52,6 +52,8 @@ export const renderApp = ({
inspector: pluginsStart.inspector,
observability: pluginsStart.observability,
observabilityRuleTypeRegistry,
dataViews: pluginsStart.dataViews,
unifiedSearch: pluginsStart.unifiedSearch,
};
// render APM feedback link in global help menu

View file

@ -1,78 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DetailView should render Discover button 1`] = `
<DiscoverErrorLink
error={
Object {
"error": Object {
"exception": Object {
"handled": true,
},
},
"http": Object {
"request": Object {
"method": "GET",
},
},
"service": Object {
"name": "myService",
},
"timestamp": Object {
"us": 0,
},
"transaction": Object {
"id": "myTransactionId",
"sampled": true,
},
"url": Object {
"full": "myUrl",
},
"user": Object {
"id": "myUserId",
},
}
}
kuery=""
>
View 10 occurrences in Discover.
</DiscoverErrorLink>
`;
exports[`DetailView should render TabContent 1`] = `
<TabContent
currentTab={
Object {
"key": "exception_stacktrace",
"label": "Exception stack trace",
}
}
error={
Object {
"context": Object {},
"error": Object {},
"timestamp": Object {
"us": 0,
},
}
}
/>
`;
exports[`DetailView should render tabs 1`] = `
<EuiTabs>
<EuiTab
isSelected={true}
key="exception_stacktrace"
onClick={[Function]}
>
Exception stack trace
</EuiTab>
<EuiTab
isSelected={false}
key="metadata"
onClick={[Function]}
>
Metadata
</EuiTab>
</EuiTabs>
`;

View file

@ -5,10 +5,33 @@
* 2.0.
*/
import { shallow } from 'enzyme';
import { render } from '@testing-library/react';
import React from 'react';
import { mockMoment } from '../../../../utils/test_helpers';
import { DetailView } from '.';
import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context';
import { createMemoryHistory } from 'history';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
const history = createMemoryHistory({
initialEntries: [
'/services/opbeans-java/errors/0000?rangeFrom=now-15m&rangeTo=now',
],
});
function MockContext({ children }: { children: React.ReactElement }) {
return (
<EuiThemeProvider>
<MockApmPluginContextWrapper history={history}>
{children}
</MockApmPluginContextWrapper>
</EuiThemeProvider>
);
}
function renderWithMockContext(element: React.ReactElement) {
return render(element, { wrapper: MockContext });
}
describe('DetailView', () => {
beforeEach(() => {
@ -17,10 +40,10 @@ describe('DetailView', () => {
});
it('should render empty state', () => {
const wrapper = shallow(
const wrapper = renderWithMockContext(
<DetailView errorGroup={{} as any} urlParams={{}} kuery="" />
);
expect(wrapper.isEmptyRender()).toBe(true);
expect(wrapper.baseElement.innerHTML).toBe('<div></div>');
});
it('should render Discover button', () => {
@ -35,23 +58,25 @@ describe('DetailView', () => {
url: { full: 'myUrl' },
service: { name: 'myService' },
user: { id: 'myUserId' },
error: { exception: { handled: true } },
error: { exception: [{ handled: true }] },
transaction: { id: 'myTransactionId', sampled: true },
} as any,
};
const wrapper = shallow(
const discoverLink = renderWithMockContext(
<DetailView errorGroup={errorGroup} urlParams={{}} kuery="" />
).find('DiscoverErrorLink');
).getByText(`View 10 occurrences in Discover`);
expect(wrapper.exists()).toBe(true);
expect(wrapper).toMatchSnapshot();
expect(discoverLink).toBeInTheDocument();
});
it('should render a Summary', () => {
const errorGroup = {
occurrencesCount: 10,
error: {
service: {
name: 'opbeans-python',
},
error: {},
timestamp: {
us: 0,
@ -59,11 +84,14 @@ describe('DetailView', () => {
} as any,
transaction: undefined,
};
const wrapper = shallow(
<DetailView errorGroup={errorGroup} urlParams={{}} kuery="" />
).find('Summary');
expect(wrapper.exists()).toBe(true);
const rendered = renderWithMockContext(
<DetailView errorGroup={errorGroup} urlParams={{}} kuery="" />
);
expect(
rendered.getByText('1337 minutes ago (mocking 0)')
).toBeInTheDocument();
});
it('should render tabs', () => {
@ -79,12 +107,14 @@ describe('DetailView', () => {
user: {},
} as any,
};
const wrapper = shallow(
<DetailView errorGroup={errorGroup} urlParams={{}} kuery="" />
).find('EuiTabs');
expect(wrapper.exists()).toBe(true);
expect(wrapper).toMatchSnapshot();
const rendered = renderWithMockContext(
<DetailView errorGroup={errorGroup} urlParams={{}} kuery="" />
);
expect(rendered.getByText('Exception stack trace')).toBeInTheDocument();
expect(rendered.getByText('Metadata')).toBeInTheDocument();
});
it('should render TabContent', () => {
@ -92,19 +122,23 @@ describe('DetailView', () => {
occurrencesCount: 10,
transaction: undefined,
error: {
service: {
name: 'opbeans-python',
},
timestamp: {
us: 0,
},
error: {},
error: {
exception: [{ handled: true }],
},
context: {},
} as any,
};
const wrapper = shallow(
const rendered = renderWithMockContext(
<DetailView errorGroup={errorGroup} urlParams={{}} kuery="" />
).find('TabContent');
);
expect(wrapper.exists()).toBe(true);
expect(wrapper).toMatchSnapshot();
expect(rendered.getByText('No stack trace available.')).toBeInTheDocument();
});
it('should render without http request info', () => {
@ -115,16 +149,20 @@ describe('DetailView', () => {
timestamp: {
us: 0,
},
error: {
exception: [{ handled: true }],
},
http: { response: { status_code: 404 } },
url: { full: 'myUrl' },
service: { name: 'myService' },
user: { id: 'myUserId' },
error: { exception: { handled: true } },
transaction: { id: 'myTransactionId', sampled: true },
} as any,
};
expect(() =>
shallow(<DetailView errorGroup={errorGroup} urlParams={{}} kuery="" />)
renderWithMockContext(
<DetailView errorGroup={errorGroup} urlParams={{}} kuery="" />
)
).not.toThrowError();
});
});

View file

@ -6,7 +6,10 @@
*/
import {
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLink,
EuiPanel,
EuiSpacer,
EuiTab,
@ -38,13 +41,12 @@ import {
logStacktraceTab,
} from './error_tabs';
import { ExceptionStacktrace } from './exception_stacktrace';
const HeaderContainer = euiStyled.div`
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: ${({ theme }) => theme.eui.euiSize};
`;
import { useApmRouter } from '../../../../hooks/use_apm_router';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { ERROR_GROUP_ID } from '../../../../../common/elasticsearch_fieldnames';
import { TraceSearchType } from '../../../../../common/trace_explorer';
import { TransactionTab } from '../../transaction_details/waterfall_with_summary/transaction_tabs';
import { useTraceExplorerEnabledSetting } from '../../../../hooks/use_trace_explorer_enabled_setting';
const TransactionLinkName = euiStyled.div`
margin-left: ${({ theme }) => theme.eui.euiSizeS};
@ -73,6 +75,15 @@ export function DetailView({ errorGroup, urlParams, kuery }: Props) {
const { detailTab, offset, comparisonEnabled } = urlParams;
const router = useApmRouter();
const isTraceExplorerEnabled = useTraceExplorerEnabledSetting();
const {
path: { groupId },
query,
} = useApmParams('/services/{serviceName}/errors/{groupId}');
if (!error) {
return null;
}
@ -85,30 +96,72 @@ export function DetailView({ errorGroup, urlParams, kuery }: Props) {
const method = error.http?.request?.method;
const status = error.http?.response?.status_code;
const traceExplorerLink = router.link('/traces/explorer', {
query: {
...query,
query: `${ERROR_GROUP_ID}:${groupId}`,
type: TraceSearchType.kql,
traceId: '',
transactionId: '',
waterfallItemId: '',
detailTab: TransactionTab.timeline,
},
});
return (
<EuiPanel hasBorder={true}>
<HeaderContainer>
<EuiTitle size="s">
<h3>
{i18n.translate(
'xpack.apm.errorGroupDetails.errorOccurrenceTitle',
{
defaultMessage: 'Error occurrence',
}
)}
</h3>
</EuiTitle>
<DiscoverErrorLink error={error} kuery={kuery}>
{i18n.translate(
'xpack.apm.errorGroupDetails.viewOccurrencesInDiscoverButtonLabel',
{
defaultMessage:
'View {occurrencesCount} {occurrencesCount, plural, one {occurrence} other {occurrences}} in Discover.',
values: { occurrencesCount },
}
)}
</DiscoverErrorLink>
</HeaderContainer>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow>
<EuiTitle size="s">
<h3>
{i18n.translate(
'xpack.apm.errorGroupDetails.errorOccurrenceTitle',
{
defaultMessage: 'Error occurrence',
}
)}
</h3>
</EuiTitle>
</EuiFlexItem>
{isTraceExplorerEnabled && (
<EuiFlexItem grow={false}>
<EuiLink href={traceExplorerLink}>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem>
<EuiIcon type="apmTrace" />
</EuiFlexItem>
<EuiFlexItem style={{ whiteSpace: 'nowrap' }}>
{i18n.translate(
'xpack.apm.errorGroupDetails.viewOccurrencesInTraceExplorer',
{
defaultMessage: 'Explore traces with this error',
}
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiLink>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<DiscoverErrorLink error={error} kuery={kuery}>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem>
<EuiIcon type="discoverApp" />
</EuiFlexItem>
<EuiFlexItem style={{ whiteSpace: 'nowrap' }}>
{i18n.translate(
'xpack.apm.errorGroupDetails.viewOccurrencesInDiscoverButtonLabel',
{
defaultMessage:
'View {occurrencesCount} {occurrencesCount, plural, one {occurrence} other {occurrences}} in Discover',
values: { occurrencesCount },
}
)}
</EuiFlexItem>
</EuiFlexGroup>
</DiscoverErrorLink>
</EuiFlexItem>
</EuiFlexGroup>
<Summary
items={[

View file

@ -18,6 +18,7 @@ import React, {
useState,
} from 'react';
import { useTheme } from '../../../hooks/use_theme';
import { useTraceExplorerEnabledSetting } from '../../../hooks/use_trace_explorer_enabled_setting';
import { getCytoscapeOptions } from './cytoscape_options';
import { useCytoscapeEventHandlers } from './use_cytoscape_event_handlers';
@ -65,8 +66,9 @@ function CytoscapeComponent({
style,
}: CytoscapeProps) {
const theme = useTheme();
const isTraceExplorerEnabled = useTraceExplorerEnabledSetting();
const [ref, cy] = useCytoscape({
...getCytoscapeOptions(theme),
...getCytoscapeOptions(theme, isTraceExplorerEnabled),
elements,
});
useCytoscapeEventHandlers({ cy, serviceName, theme });

View file

@ -104,7 +104,10 @@ function isService(el: cytoscape.NodeSingular) {
return el.data(SERVICE_NAME) !== undefined;
}
const getStyle = (theme: EuiTheme): cytoscape.Stylesheet[] => {
const getStyle = (
theme: EuiTheme,
isTraceExplorerEnabled: boolean
): cytoscape.Stylesheet[] => {
const lineColor = theme.eui.euiColorMediumShade;
return [
{
@ -211,6 +214,20 @@ const getStyle = (theme: EuiTheme): cytoscape.Stylesheet[] => {
'target-arrow-color': theme.eui.euiColorDarkShade,
},
},
...(isTraceExplorerEnabled
? [
{
selector: 'edge.hover',
style: {
width: 4,
'z-index': zIndexEdgeHover,
'line-color': theme.eui.euiColorDarkShade,
'source-arrow-color': theme.eui.euiColorDarkShade,
'target-arrow-color': theme.eui.euiColorDarkShade,
},
},
]
: []),
{
selector: 'node.hover',
style: {
@ -256,10 +273,11 @@ ${theme.eui.euiColorLightShade}`,
});
export const getCytoscapeOptions = (
theme: EuiTheme
theme: EuiTheme,
isTraceExplorerEnabled: boolean
): cytoscape.CytoscapeOptions => ({
boxSelectionEnabled: false,
maxZoom: 3,
minZoom: 0.2,
style: getStyle(theme),
style: getStyle(theme, isTraceExplorerEnabled),
});

View file

@ -11,6 +11,7 @@ import { TypeOf } from '@kbn/typed-react-router-config';
import { METRIC_TYPE } from '@kbn/analytics';
import React from 'react';
import { useUiTracker } from '@kbn/observability-plugin/public';
import { NodeDataDefinition } from 'cytoscape';
import { ContentsProps } from '.';
import { useAnyOfApmParams } from '../../../../hooks/use_apm_params';
import { useApmRouter } from '../../../../hooks/use_apm_router';
@ -27,11 +28,13 @@ const INITIAL_STATE: Partial<BackendReturn> = {
};
export function BackendContents({
nodeData,
elementData,
environment,
start,
end,
}: ContentsProps) {
const nodeData = elementData as NodeDataDefinition;
const { query } = useAnyOfApmParams(
'/service-map',
'/services/{serviceName}/service-map'

View file

@ -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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButton, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { METRIC_TYPE } from '@kbn/analytics';
import React from 'react';
import { useUiTracker } from '@kbn/observability-plugin/public';
import { EdgeDataDefinition } from 'cytoscape';
import { ContentsProps } from '.';
import { useAnyOfApmParams } from '../../../../hooks/use_apm_params';
import { useApmRouter } from '../../../../hooks/use_apm_router';
import { TraceSearchType } from '../../../../../common/trace_explorer';
import { TransactionTab } from '../../transaction_details/waterfall_with_summary/transaction_tabs';
import {
SERVICE_NAME,
SPAN_DESTINATION_SERVICE_RESOURCE,
} from '../../../../../common/elasticsearch_fieldnames';
export function EdgeContents({ elementData }: ContentsProps) {
const edgeData = elementData as EdgeDataDefinition;
const { query } = useAnyOfApmParams(
'/service-map',
'/services/{serviceName}/service-map'
);
const apmRouter = useApmRouter();
const sourceService = edgeData.sourceData['service.name'];
const trackEvent = useUiTracker();
let traceQuery: string;
if (SERVICE_NAME in edgeData.targetData) {
traceQuery =
`sequence by trace.id\n` +
` [ span where service.name == "${sourceService}" and span.destination.service.resource != null ] by span.id\n` +
` [ transaction where service.name == "${edgeData.targetData[SERVICE_NAME]}" ] by parent.id`;
} else {
traceQuery =
`sequence by trace.id\n` +
` [ transaction where service.name == "${sourceService}" ]\n` +
` [ span where service.name == "${sourceService}" and span.destination.service.resource == "${edgeData.targetData[SPAN_DESTINATION_SERVICE_RESOURCE]}" ]`;
}
const url = apmRouter.link('/traces/explorer', {
query: {
...query,
type: TraceSearchType.eql,
query: traceQuery,
waterfallItemId: '',
traceId: '',
transactionId: '',
detailTab: TransactionTab.timeline,
},
});
return (
<EuiFlexItem>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click*/}
<EuiButton
href={url}
fill={true}
onClick={() => {
trackEvent({
app: 'apm',
metricType: METRIC_TYPE.CLICK,
metric: 'service_map_to_trace_explorer',
});
}}
>
{i18n.translate('xpack.apm.serviceMap.viewInTraceExplorer', {
defaultMessage: 'Explore traces',
})}
</EuiButton>
</EuiFlexItem>
);
}

View file

@ -13,6 +13,7 @@ import {
} from '@elastic/eui';
import React, { Fragment } from 'react';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { NodeDataDefinition } from 'cytoscape';
import { ContentsProps } from '.';
import {
SPAN_DESTINATION_SERVICE_RESOURCE,
@ -26,7 +27,8 @@ const ExternalResourcesList = euiStyled.section`
overflow: auto;
`;
export function ExternalsListContents({ nodeData }: ContentsProps) {
export function ExternalsListContents({ elementData }: ContentsProps) {
const nodeData = elementData as NodeDataDefinition;
return (
<EuiFlexItem>
<ExternalResourcesList>

View file

@ -28,32 +28,47 @@ import {
} from '../../../../../common/elasticsearch_fieldnames';
import { Environment } from '../../../../../common/environment_rt';
import { useTheme } from '../../../../hooks/use_theme';
import { useTraceExplorerEnabledSetting } from '../../../../hooks/use_trace_explorer_enabled_setting';
import { CytoscapeContext } from '../cytoscape';
import { getAnimationOptions, popoverWidth } from '../cytoscape_options';
import { BackendContents } from './backend_contents';
import { EdgeContents } from './edge_contents';
import { ExternalsListContents } from './externals_list_contents';
import { ResourceContents } from './resource_contents';
import { ServiceContents } from './service_contents';
function getContentsComponent(selectedNodeData: cytoscape.NodeDataDefinition) {
function getContentsComponent(
selectedElementData:
| cytoscape.NodeDataDefinition
| cytoscape.EdgeDataDefinition,
isTraceExplorerEnabled: boolean
) {
if (
selectedNodeData.groupedConnections &&
Array.isArray(selectedNodeData.groupedConnections)
selectedElementData.groupedConnections &&
Array.isArray(selectedElementData.groupedConnections)
) {
return ExternalsListContents;
}
if (selectedNodeData[SERVICE_NAME]) {
if (selectedElementData[SERVICE_NAME]) {
return ServiceContents;
}
if (selectedNodeData[SPAN_TYPE] === 'resource') {
if (selectedElementData[SPAN_TYPE] === 'resource') {
return ResourceContents;
}
if (
isTraceExplorerEnabled &&
selectedElementData.source &&
selectedElementData.target
) {
return EdgeContents;
}
return BackendContents;
}
export interface ContentsProps {
nodeData: cytoscape.NodeDataDefinition;
elementData: cytoscape.NodeDataDefinition | cytoscape.ElementDataDefinition;
environment: Environment;
kuery: string;
start: string;
@ -78,19 +93,26 @@ export function Popover({
}: PopoverProps) {
const theme = useTheme();
const cy = useContext(CytoscapeContext);
const [selectedNode, setSelectedNode] = useState<
cytoscape.NodeSingular | undefined
const [selectedElement, setSelectedElement] = useState<
cytoscape.NodeSingular | cytoscape.EdgeSingular | undefined
>(undefined);
const deselect = useCallback(() => {
if (cy) {
cy.elements().unselect();
}
setSelectedNode(undefined);
}, [cy, setSelectedNode]);
const renderedHeight = selectedNode?.renderedHeight() ?? 0;
const renderedWidth = selectedNode?.renderedWidth() ?? 0;
const { x, y } = selectedNode?.renderedPosition() ?? { x: -10000, y: -10000 };
const isOpen = !!selectedNode;
setSelectedElement(undefined);
}, [cy, setSelectedElement]);
const isTraceExplorerEnabled = useTraceExplorerEnabledSetting();
const renderedHeight = selectedElement?.renderedHeight() ?? 0;
const renderedWidth = selectedElement?.renderedWidth() ?? 0;
const box = selectedElement?.renderedBoundingBox({});
const x = box ? box.x1 + box.w / 2 : -10000;
const y = box ? box.y1 + box.h / 2 : -10000;
const isOpen = !!selectedElement;
const triggerStyle: CSSProperties = {
background: 'transparent',
height: renderedHeight,
@ -99,20 +121,20 @@ export function Popover({
};
const trigger = <div style={triggerStyle} />;
const zoom = cy?.zoom() ?? 1;
const height = selectedNode?.height() ?? 0;
const height = selectedElement?.height() ?? 0;
const translateY = y - ((zoom + 1) * height) / 4;
const popoverStyle: CSSProperties = {
position: 'absolute',
transform: `translate(${x}px, ${translateY}px)`,
};
const selectedNodeData = selectedNode?.data() ?? {};
const selectedElementData = selectedElement?.data() ?? {};
const popoverRef = useRef<EuiPopover>(null);
const selectedNodeId = selectedNodeData.id;
const selectedElementId = selectedElementData.id;
// Set up Cytoscape event handlers
useEffect(() => {
const selectHandler: cytoscape.EventHandler = (event) => {
setSelectedNode(event.target);
setSelectedElement(event.target);
};
if (cy) {
@ -120,6 +142,10 @@ export function Popover({
cy.on('unselect', 'node', deselect);
cy.on('viewport', deselect);
cy.on('drag', 'node', deselect);
if (isTraceExplorerEnabled) {
cy.on('select', 'edge', selectHandler);
cy.on('unselect', 'edge', deselect);
}
}
return () => {
@ -128,9 +154,11 @@ export function Popover({
cy.removeListener('unselect', 'node', deselect);
cy.removeListener('viewport', undefined, deselect);
cy.removeListener('drag', 'node', deselect);
cy.removeListener('select', 'edge', selectHandler);
cy.removeListener('unselect', 'edge', deselect);
}
};
}, [cy, deselect]);
}, [cy, deselect, isTraceExplorerEnabled]);
// Handle positioning of popover. This makes it so the popover positions
// itself correctly and the arrows are always pointing to where they should.
@ -146,20 +174,23 @@ export function Popover({
if (cy) {
cy.animate({
...getAnimationOptions(theme),
center: { eles: cy.getElementById(selectedNodeId) },
center: { eles: cy.getElementById(selectedElementId) },
});
}
},
[cy, selectedNodeId, theme]
[cy, selectedElementId, theme]
);
const isAlreadyFocused = focusedServiceName === selectedNodeId;
const isAlreadyFocused = focusedServiceName === selectedElementId;
const onFocusClick = isAlreadyFocused
? centerSelectedNode
: (_event: MouseEvent<HTMLAnchorElement>) => deselect();
const ContentsComponent = getContentsComponent(selectedNodeData);
const ContentsComponent = getContentsComponent(
selectedElementData,
isTraceExplorerEnabled
);
return (
<EuiPopover
@ -179,14 +210,14 @@ export function Popover({
<EuiFlexItem>
<EuiTitle size="xxs">
<h3 style={{ wordBreak: 'break-all' }}>
{selectedNodeData.label ?? selectedNodeId}
{selectedElementData.label ?? selectedElementId}
</h3>
</EuiTitle>
<EuiHorizontalRule margin="xs" />
</EuiFlexItem>
<ContentsComponent
onFocusClick={onFocusClick}
nodeData={selectedNodeData}
elementData={selectedElementData}
environment={environment}
kuery={kuery}
start={start}

View file

@ -12,6 +12,7 @@ import {
import { i18n } from '@kbn/i18n';
import React from 'react';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { NodeDataDefinition } from 'cytoscape';
import type { ContentsProps } from '.';
import {
SPAN_SUBTYPE,
@ -28,7 +29,8 @@ const SubduedDescriptionListTitle = euiStyled(EuiDescriptionListTitle)`
}
`;
export function ResourceContents({ nodeData }: ContentsProps) {
export function ResourceContents({ elementData }: ContentsProps) {
const nodeData = elementData as NodeDataDefinition;
const subtype = nodeData[SPAN_SUBTYPE];
const type = nodeData[SPAN_TYPE];

View file

@ -15,6 +15,7 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { NodeDataDefinition } from 'cytoscape';
import { useApmParams } from '../../../../hooks/use_apm_params';
import type { ContentsProps } from '.';
import { useApmRouter } from '../../../../hooks/use_apm_router';
@ -34,10 +35,11 @@ const INITIAL_STATE: ServiceNodeReturn = {
export function ServiceContents({
onFocusClick,
nodeData,
elementData,
environment,
kuery,
}: ContentsProps) {
const nodeData = elementData as NodeDataDefinition;
const apmRouter = useApmRouter();
const { query } = useApmParams('/*');

View file

@ -0,0 +1,61 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { useApmParams } from '../../../hooks/use_apm_params';
import { SearchBar } from '../../shared/search_bar';
import { TraceList } from './trace_list';
import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to_transactions_fetcher';
import { AggregatedTransactionsBadge } from '../../shared/aggregated_transactions_badge';
import { useTimeRange } from '../../../hooks/use_time_range';
import { useProgressiveFetcher } from '../../../hooks/use_progressive_fetcher';
export function TopTracesOverview() {
const {
query: { environment, kuery, rangeFrom, rangeTo },
} = useApmParams('/traces');
const { fallbackToTransactions } = useFallbackToTransactionsFetcher({
kuery,
});
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const response = useProgressiveFetcher(
(callApmApi) => {
if (start && end) {
return callApmApi('GET /internal/apm/traces', {
params: {
query: {
environment,
kuery,
start,
end,
},
},
});
}
},
[environment, kuery, start, end]
);
return (
<>
<SearchBar />
{fallbackToTransactions && (
<EuiFlexGroup>
<EuiFlexItem>
<AggregatedTransactionsBadge />
</EuiFlexItem>
</EuiFlexGroup>
)}
<TraceList response={response} />
</>
);
}

View file

@ -16,6 +16,7 @@ import {
asTransactionRate,
} from '../../../../common/utils/formatters';
import { useApmParams } from '../../../hooks/use_apm_params';
import { FetcherResult, FETCH_STATUS } from '../../../hooks/use_fetcher';
import { APIReturnType } from '../../../services/rest/create_call_apm_api';
import { truncate } from '../../../utils/style';
import { EmptyMessage } from '../../shared/empty_message';
@ -26,19 +27,17 @@ import { ServiceLink } from '../../shared/service_link';
import { TruncateWithTooltip } from '../../shared/truncate_with_tooltip';
import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n';
type TraceGroup = APIReturnType<'GET /internal/apm/traces'>['items'][0];
const StyledTransactionLink = euiStyled(TransactionDetailLink)`
font-size: ${({ theme }) => theme.eui.euiFontSizeS};
${truncate('100%')};
`;
interface Props {
items: TraceGroup[];
isLoading: boolean;
isFailure: boolean;
response: FetcherResult<APIReturnType<'GET /internal/apm/traces'>>;
}
type TraceGroup = Required<Props['response']>['data']['items'][number];
export function getTraceListColumns({
query,
}: {
@ -153,7 +152,9 @@ const noItemsMessage = (
/>
);
export function TraceList({ items = [], isLoading, isFailure }: Props) {
export function TraceList({ response }: Props) {
const { data: { items } = { items: [] }, status } = response;
const { query } = useApmParams('/traces');
const traceListColumns = useMemo(
@ -162,8 +163,8 @@ export function TraceList({ items = [], isLoading, isFailure }: Props) {
);
return (
<ManagedTable
isLoading={isLoading}
error={isFailure}
isLoading={status === FETCH_STATUS.LOADING}
error={status === FETCH_STATUS.FAILURE}
columns={traceListColumns}
items={items}
initialSortField="impact"

View file

@ -0,0 +1,157 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import {
TraceSearchQuery,
TraceSearchType,
} from '../../../../common/trace_explorer';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useFetcher, FETCH_STATUS } from '../../../hooks/use_fetcher';
import { useTimeRange } from '../../../hooks/use_time_range';
import { ApmDatePicker } from '../../shared/date_picker/apm_date_picker';
import { fromQuery, toQuery, push } from '../../shared/links/url_helpers';
import { useWaterfallFetcher } from '../transaction_details/use_waterfall_fetcher';
import { WaterfallWithSummary } from '../transaction_details/waterfall_with_summary';
import { TraceSearchBox } from './trace_search_box';
export function TraceExplorer() {
const [query, setQuery] = useState<TraceSearchQuery>({
query: '',
type: TraceSearchType.kql,
});
const {
query: {
rangeFrom,
rangeTo,
environment,
query: queryFromUrlParams,
type: typeFromUrlParams,
traceId,
transactionId,
waterfallItemId,
detailTab,
},
} = useApmParams('/traces/explorer');
const history = useHistory();
useEffect(() => {
setQuery({
query: queryFromUrlParams,
type: typeFromUrlParams,
});
}, [queryFromUrlParams, typeFromUrlParams]);
const { start, end } = useTimeRange({
rangeFrom,
rangeTo,
});
const { data: traceSamplesData, status: traceSamplesStatus } = useFetcher(
(callApmApi) => {
return callApmApi('GET /internal/apm/traces/find', {
params: {
query: {
start,
end,
environment,
query: queryFromUrlParams,
type: typeFromUrlParams,
},
},
});
},
[start, end, environment, queryFromUrlParams, typeFromUrlParams]
);
useEffect(() => {
const nextSample = traceSamplesData?.samples[0];
const nextWaterfallItemId = '';
history.replace({
...history.location,
search: fromQuery({
...toQuery(history.location.search),
traceId: nextSample?.traceId ?? '',
transactionId: nextSample?.transactionId,
waterfallItemId: nextWaterfallItemId,
}),
});
}, [traceSamplesData, history]);
const { waterfall, status: waterfallStatus } = useWaterfallFetcher({
traceId,
transactionId,
start,
end,
});
const isLoading =
traceSamplesStatus === FETCH_STATUS.LOADING ||
waterfallStatus === FETCH_STATUS.LOADING;
return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiFlexGroup direction="row">
<EuiFlexItem grow>
<TraceSearchBox
query={query}
error={false}
loading={false}
onQueryCommit={() => {
history.push({
...history.location,
search: fromQuery({
...toQuery(history.location.search),
query: query.query,
type: query.type,
}),
});
}}
onQueryChange={(nextQuery) => {
setQuery(nextQuery);
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ApmDatePicker />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<WaterfallWithSummary
environment={environment}
isLoading={isLoading}
onSampleClick={(sample) => {
push(history, {
query: {
traceId: sample.traceId,
transactionId: sample.transactionId,
waterfallItemId: '',
},
});
}}
onTabClick={(nextDetailTab) => {
push(history, {
query: {
detailTab: nextDetailTab,
},
});
}}
traceSamples={traceSamplesData?.samples ?? []}
waterfall={waterfall}
detailTab={detailTab}
waterfallItemId={waterfallItemId}
serviceName={waterfall.entryWaterfallTransaction?.doc.service.name}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,186 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiText,
EuiSelect,
EuiSelectOption,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { QueryStringInput } from '@kbn/unified-search-plugin/public';
import React from 'react';
import {
TraceSearchQuery,
TraceSearchType,
} from '../../../../../common/trace_explorer';
import { useStaticDataView } from '../../../../hooks/use_static_data_view';
import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context';
import { EQLCodeEditorSuggestionType } from '../../../shared/eql_code_editor/constants';
import { LazilyLoadedEQLCodeEditor } from '../../../shared/eql_code_editor/lazily_loaded_code_editor';
interface Props {
query: TraceSearchQuery;
message?: string;
error: boolean;
onQueryChange: (query: TraceSearchQuery) => void;
onQueryCommit: () => void;
loading: boolean;
}
const options: EuiSelectOption[] = [
{
value: TraceSearchType.kql,
text: i18n.translate('xpack.apm.traceSearchBox.traceSearchTypeKql', {
defaultMessage: 'KQL',
}),
},
{
value: TraceSearchType.eql,
text: i18n.translate('xpack.apm.traceSearchBox.traceSearchTypeEql', {
defaultMessage: 'EQL',
}),
},
];
export function TraceSearchBox({
query,
onQueryChange,
onQueryCommit,
message,
error,
loading,
}: Props) {
const { unifiedSearch } = useApmPluginContext();
const { value: dataView } = useStaticDataView();
return (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiFlexGroup direction="row" gutterSize="s">
<EuiFlexItem grow>
{query.type === TraceSearchType.eql ? (
<LazilyLoadedEQLCodeEditor
value={query.query}
onChange={(value) => {
onQueryChange({
...query,
query: value,
});
}}
onBlur={() => {
onQueryCommit();
}}
getSuggestions={async (request) => {
switch (request.type) {
case EQLCodeEditorSuggestionType.EventType:
return ['transaction', 'span', 'error'];
case EQLCodeEditorSuggestionType.Field:
return (
dataView?.fields.map((field) => field.name) ?? []
);
case EQLCodeEditorSuggestionType.Value:
const field = dataView?.getFieldByName(request.field);
if (!dataView || !field) {
return [];
}
const suggestions: string[] =
await unifiedSearch.autocomplete.getValueSuggestions(
{
field,
indexPattern: dataView,
query: request.value,
useTimeRange: true,
method: 'terms_agg',
}
);
return suggestions.slice(0, 15);
}
}}
width="100%"
height="100px"
/>
) : (
<form>
<QueryStringInput
disableLanguageSwitcher
indexPatterns={dataView ? [dataView] : []}
query={{
query: query.query,
language: 'kuery',
}}
onSubmit={() => {
onQueryCommit();
}}
disableAutoFocus
submitOnBlur
isClearable
onChange={(e) => {
onQueryChange({
...query,
query: String(e.query ?? ''),
});
}}
/>
</form>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSelect
id="select-query-language"
value={query.type}
onChange={(e) => {
onQueryChange({
query: '',
type: e.target.value as TraceSearchType,
});
}}
options={options}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup
direction="row"
gutterSize="s"
alignItems="center"
justifyContent="flexEnd"
>
<EuiFlexItem>
<EuiText color={error ? 'danger' : 'subdued'} size="xs">
{message}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
isLoading={loading}
onClick={() => {
onQueryCommit();
}}
iconType="search"
>
{i18n.translate('xpack.apm.traceSearchBox.refreshButton', {
defaultMessage: 'Search',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -4,69 +4,81 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useApmRouter } from '../../../hooks/use_apm_router';
import { useApmParams } from '../../../hooks/use_apm_params';
import { FETCH_STATUS } from '../../../hooks/use_fetcher';
import { APIReturnType } from '../../../services/rest/create_call_apm_api';
import { SearchBar } from '../../shared/search_bar';
import { TraceList } from './trace_list';
import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to_transactions_fetcher';
import { AggregatedTransactionsBadge } from '../../shared/aggregated_transactions_badge';
import { useTimeRange } from '../../../hooks/use_time_range';
import { useProgressiveFetcher } from '../../../hooks/use_progressive_fetcher';
import { useApmRoutePath } from '../../../hooks/use_apm_route_path';
import { TraceSearchType } from '../../../../common/trace_explorer';
import { TransactionTab } from '../transaction_details/waterfall_with_summary/transaction_tabs';
import { useTraceExplorerEnabledSetting } from '../../../hooks/use_trace_explorer_enabled_setting';
type TracesAPIResponse = APIReturnType<'GET /internal/apm/traces'>;
const DEFAULT_RESPONSE: TracesAPIResponse = {
items: [],
};
export function TraceOverview({ children }: { children: React.ReactElement }) {
const isTraceExplorerEnabled = useTraceExplorerEnabledSetting();
export function TraceOverview() {
const {
query: { environment, kuery, rangeFrom, rangeTo },
} = useApmParams('/traces');
const { fallbackToTransactions } = useFallbackToTransactionsFetcher({
kuery,
const router = useApmRouter();
const { query } = useApmParams('/traces');
const routePath = useApmRoutePath();
if (!isTraceExplorerEnabled) {
return children;
}
const explorerLink = router.link('/traces/explorer', {
query: {
comparisonEnabled: query.comparisonEnabled,
environment: query.environment,
kuery: query.kuery,
rangeFrom: query.rangeFrom,
rangeTo: query.rangeTo,
offset: query.offset,
refreshInterval: query.refreshInterval,
refreshPaused: query.refreshPaused,
query: '',
type: TraceSearchType.kql,
waterfallItemId: '',
traceId: '',
transactionId: '',
detailTab: TransactionTab.timeline,
},
});
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const { status, data = DEFAULT_RESPONSE } = useProgressiveFetcher(
(callApmApi) => {
if (start && end) {
return callApmApi('GET /internal/apm/traces', {
params: {
query: {
environment,
kuery,
start,
end,
},
},
});
}
const topTracesLink = router.link('/traces', {
query: {
comparisonEnabled: query.comparisonEnabled,
environment: query.environment,
kuery: query.kuery,
rangeFrom: query.rangeFrom,
rangeTo: query.rangeTo,
offset: query.offset,
refreshInterval: query.refreshInterval,
refreshPaused: query.refreshPaused,
},
[environment, kuery, start, end]
);
});
return (
<>
<SearchBar />
{fallbackToTransactions && (
<EuiFlexGroup>
<EuiFlexItem>
<AggregatedTransactionsBadge />
</EuiFlexItem>
</EuiFlexGroup>
)}
<TraceList
items={data.items}
isLoading={status === FETCH_STATUS.LOADING}
isFailure={status === FETCH_STATUS.FAILURE}
/>
</>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiTabs>
<EuiTab href={topTracesLink} isSelected={routePath === '/traces'}>
{i18n.translate('xpack.apm.traceOverview.topTracesTab', {
defaultMessage: 'Top traces',
})}
</EuiTab>
<EuiTab
href={explorerLink}
isSelected={routePath === '/traces/explorer'}
>
{i18n.translate('xpack.apm.traceOverview.traceExplorerTab', {
defaultMessage: 'Explorer',
})}
</EuiTab>
</EuiTabs>
</EuiFlexItem>
<EuiFlexItem>{children}</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import React from 'react';
import { BrushEndListener, XYBrushEvent } from '@elastic/charts';
import {
EuiBadge,
@ -16,6 +15,8 @@ import {
EuiText,
EuiTitle,
} from '@elastic/eui';
import React from 'react';
import { useHistory } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
@ -32,9 +33,14 @@ import type { TabContentProps } from '../types';
import { useWaterfallFetcher } from '../use_waterfall_fetcher';
import { WaterfallWithSummary } from '../waterfall_with_summary';
import { useTransactionDistributionChartData } from './use_transaction_distribution_chart_data';
import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { useTimeRange } from '../../../../hooks/use_time_range';
import { HeightRetainer } from '../../../shared/height_retainer';
import { fromQuery, toQuery } from '../../../shared/links/url_helpers';
import { ChartTitleToolTip } from '../../correlations/chart_title_tool_tip';
import { useTransactionDistributionChartData } from './use_transaction_distribution_chart_data';
import { TransactionTab } from '../waterfall_with_summary/transaction_tabs';
// Enforce min height so it's consistent across all tabs on the same level
// to prevent "flickering" behavior
@ -70,8 +76,28 @@ export function TransactionDistribution({
traceSamplesStatus,
}: TransactionDistributionProps) {
const { urlParams } = useLegacyUrlParams();
const { waterfall, status: waterfallStatus } = useWaterfallFetcher();
const { traceId, transactionId } = urlParams;
const {
query: { rangeFrom, rangeTo },
} = useApmParams('/services/{serviceName}/transactions/view');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const history = useHistory();
const { waterfall, status: waterfallStatus } = useWaterfallFetcher({
traceId,
transactionId,
start,
end,
});
const { waterfallItemId, detailTab } = urlParams;
const {
query: { environment },
} = useApmParams('/services/{serviceName}/transactions/view');
const { serviceName } = useApmServiceContext();
const isLoading =
waterfallStatus === FETCH_STATUS.LOADING ||
traceSamplesStatus === FETCH_STATUS.LOADING;
@ -193,7 +219,29 @@ export function TransactionDistribution({
<EuiSpacer size="s" />
<WaterfallWithSummary
urlParams={urlParams}
environment={environment}
onSampleClick={(sample) => {
history.push({
...history.location,
search: fromQuery({
...toQuery(history.location.search),
transactionId: sample.transactionId,
traceId: sample.traceId,
}),
});
}}
onTabClick={(tab) => {
history.replace({
...history.location,
search: fromQuery({
...toQuery(history.location.search),
detailTab: tab,
}),
});
}}
serviceName={serviceName}
waterfallItemId={waterfallItemId}
detailTab={detailTab as TransactionTab | undefined}
waterfall={waterfall}
isLoading={isLoading}
traceSamples={traceSamples}

View file

@ -6,10 +6,7 @@
*/
import { useMemo } from 'react';
import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_params';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useFetcher } from '../../../hooks/use_fetcher';
import { useTimeRange } from '../../../hooks/use_time_range';
import { APIReturnType } from '../../../services/rest/create_call_apm_api';
import { getWaterfall } from './waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers';
@ -20,16 +17,17 @@ const INITIAL_DATA: APIReturnType<'GET /internal/apm/traces/{traceId}'> = {
linkedChildrenOfSpanCountBySpanId: {},
};
export function useWaterfallFetcher() {
const { urlParams } = useLegacyUrlParams();
const { traceId, transactionId } = urlParams;
const {
query: { rangeFrom, rangeTo },
} = useApmParams('/services/{serviceName}/transactions/view');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
export function useWaterfallFetcher({
traceId,
transactionId,
start,
end,
}: {
traceId?: string;
transactionId?: string;
start: string;
end: string;
}) {
const {
data = INITIAL_DATA,
status,

View file

@ -15,38 +15,40 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import type { ApmUrlParams } from '../../../../context/url_params_context/types';
import { fromQuery, toQuery } from '../../../shared/links/url_helpers';
import { LoadingStatePrompt } from '../../../shared/loading_state_prompt';
import { TransactionSummary } from '../../../shared/summary/transaction_summary';
import { TransactionActionMenu } from '../../../shared/transaction_action_menu/transaction_action_menu';
import type { TraceSample } from '../../../../hooks/use_transaction_trace_samples_fetcher';
import { MaybeViewTraceLink } from './maybe_view_trace_link';
import { TransactionTabs } from './transaction_tabs';
import { TransactionTab, TransactionTabs } from './transaction_tabs';
import { IWaterfall } from './waterfall_container/waterfall/waterfall_helpers/waterfall_helpers';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { Environment } from '../../../../../common/environment_rt';
interface Props {
urlParams: ApmUrlParams;
waterfall: IWaterfall;
isLoading: boolean;
traceSamples: TraceSample[];
environment: Environment;
onSampleClick: (sample: { transactionId: string; traceId: string }) => void;
onTabClick: (tab: string) => void;
serviceName?: string;
waterfallItemId?: string;
detailTab?: TransactionTab;
}
export function WaterfallWithSummary({
urlParams,
waterfall,
isLoading,
traceSamples,
environment,
onSampleClick,
onTabClick,
serviceName,
waterfallItemId,
detailTab,
}: Props) {
const history = useHistory();
const [sampleActivePage, setSampleActivePage] = useState(0);
const {
query: { environment },
} = useApmParams('/services/{serviceName}/transactions/view');
useEffect(() => {
setSampleActivePage(0);
}, [traceSamples]);
@ -54,14 +56,7 @@ export function WaterfallWithSummary({
const goToSample = (index: number) => {
setSampleActivePage(index);
const sample = traceSamples[index];
history.push({
...history.location,
search: fromQuery({
...toQuery(history.location.search),
transactionId: sample.transactionId,
traceId: sample.traceId,
}),
});
onSampleClick(sample);
};
const { entryWaterfallTransaction } = waterfall;
@ -137,7 +132,10 @@ export function WaterfallWithSummary({
<EuiSpacer size="s" />
<TransactionTabs
transaction={entryTransaction}
urlParams={urlParams}
detailTab={detailTab}
serviceName={serviceName}
waterfallItemId={waterfallItemId}
onTabClick={onTabClick}
waterfall={waterfall}
/>
</>

View file

@ -13,7 +13,8 @@ import { Transaction as ITransaction } from '../../../../../typings/es_schemas/u
import { TransactionDetailLink } from '../../../shared/links/apm/transaction_detail_link';
import { IWaterfall } from './waterfall_container/waterfall/waterfall_helpers/waterfall_helpers';
import { Environment } from '../../../../../common/environment_rt';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { useAnyOfApmParams } from '../../../../hooks/use_apm_params';
import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types';
function FullTraceButton({
isLoading,
@ -48,8 +49,16 @@ export function MaybeViewTraceLink({
environment: Environment;
}) {
const {
query: { latencyAggregationType, comparisonEnabled, offset },
} = useApmParams('/services/{serviceName}/transactions/view');
query,
query: { comparisonEnabled, offset },
} = useAnyOfApmParams(
'/services/{serviceName}/transactions/view',
'/traces/explorer'
);
const latencyAggregationType =
('latencyAggregationType' in query && query.latencyAggregationType) ||
LatencyAggregationType.avg;
if (isLoading || !transaction) {
return <FullTraceButton isLoading={isLoading} />;

View file

@ -7,27 +7,32 @@
import { EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useHistory } from 'react-router-dom';
import { LogStream } from '@kbn/infra-plugin/public';
import React from 'react';
import { Transaction } from '../../../../../typings/es_schemas/ui/transaction';
import type { ApmUrlParams } from '../../../../context/url_params_context/types';
import { fromQuery, toQuery } from '../../../shared/links/url_helpers';
import { TransactionMetadata } from '../../../shared/metadata_table/transaction_metadata';
import { WaterfallContainer } from './waterfall_container';
import { IWaterfall } from './waterfall_container/waterfall/waterfall_helpers/waterfall_helpers';
interface Props {
transaction: Transaction;
urlParams: ApmUrlParams;
waterfall: IWaterfall;
detailTab?: TransactionTab;
serviceName?: string;
waterfallItemId?: string;
onTabClick: (tab: TransactionTab) => void;
}
export function TransactionTabs({ transaction, urlParams, waterfall }: Props) {
const history = useHistory();
export function TransactionTabs({
transaction,
waterfall,
detailTab,
waterfallItemId,
serviceName,
onTabClick,
}: Props) {
const tabs = [timelineTab, metadataTab, logsTab];
const currentTab =
tabs.find(({ key }) => key === urlParams.detailTab) ?? timelineTab;
const currentTab = tabs.find(({ key }) => key === detailTab) ?? timelineTab;
const TabContent = currentTab.component;
return (
@ -37,13 +42,7 @@ export function TransactionTabs({ transaction, urlParams, waterfall }: Props) {
return (
<EuiTab
onClick={() => {
history.replace({
...history.location,
search: fromQuery({
...toQuery(history.location.search),
detailTab: key,
}),
});
onTabClick(key);
}}
isSelected={currentTab.key === key}
key={key}
@ -57,7 +56,8 @@ export function TransactionTabs({ transaction, urlParams, waterfall }: Props) {
<EuiSpacer />
<TabContent
urlParams={urlParams}
waterfallItemId={waterfallItemId}
serviceName={serviceName}
waterfall={waterfall}
transaction={transaction}
/>
@ -65,8 +65,14 @@ export function TransactionTabs({ transaction, urlParams, waterfall }: Props) {
);
}
export enum TransactionTab {
timeline = 'timeline',
metadata = 'metadata',
logs = 'logs',
}
const timelineTab = {
key: 'timeline',
key: TransactionTab.timeline,
label: i18n.translate('xpack.apm.propertiesTable.tabs.timelineLabel', {
defaultMessage: 'Timeline',
}),
@ -74,7 +80,7 @@ const timelineTab = {
};
const metadataTab = {
key: 'metadata',
key: TransactionTab.metadata,
label: i18n.translate('xpack.apm.propertiesTable.tabs.metadataLabel', {
defaultMessage: 'Metadata',
}),
@ -82,7 +88,7 @@ const metadataTab = {
};
const logsTab = {
key: 'logs',
key: TransactionTab.logs,
label: i18n.translate('xpack.apm.propertiesTable.tabs.logsLabel', {
defaultMessage: 'Logs',
}),
@ -90,13 +96,21 @@ const logsTab = {
};
function TimelineTabContent({
urlParams,
waterfall,
waterfallItemId,
serviceName,
}: {
urlParams: ApmUrlParams;
waterfallItemId?: string;
serviceName?: string;
waterfall: IWaterfall;
}) {
return <WaterfallContainer urlParams={urlParams} waterfall={waterfall} />;
return (
<WaterfallContainer
waterfallItemId={waterfallItemId}
serviceName={serviceName}
waterfall={waterfall}
/>
);
}
function MetadataTabContent({ transaction }: { transaction: Transaction }) {

View file

@ -7,23 +7,24 @@
import React from 'react';
import { keyBy } from 'lodash';
import type { ApmUrlParams } from '../../../../../context/url_params_context/types';
import {
IWaterfall,
WaterfallLegendType,
} from './waterfall/waterfall_helpers/waterfall_helpers';
import { Waterfall } from './waterfall';
import { WaterfallLegends } from './waterfall_legends';
import { useApmServiceContext } from '../../../../../context/apm_service/use_apm_service_context';
interface Props {
urlParams: ApmUrlParams;
waterfallItemId?: string;
serviceName?: string;
waterfall: IWaterfall;
}
export function WaterfallContainer({ urlParams, waterfall }: Props) {
const { serviceName } = useApmServiceContext();
export function WaterfallContainer({
serviceName,
waterfallItemId,
waterfall,
}: Props) {
if (!waterfall) {
return null;
}
@ -75,10 +76,7 @@ export function WaterfallContainer({ urlParams, waterfall }: Props) {
return (
<div>
<WaterfallLegends legends={legendsWithFallbackLabel} type={colorBy} />
<Waterfall
waterfallItemId={urlParams.waterfallItemId}
waterfall={waterfall}
/>
<Waterfall waterfallItemId={waterfallItemId} waterfall={waterfall} />
</div>
);
}

View file

@ -12,8 +12,9 @@ import {
TRANSACTION_NAME,
} from '../../../../../../../common/elasticsearch_fieldnames';
import { getNextEnvironmentUrlParam } from '../../../../../../../common/environment_filter_values';
import { LatencyAggregationType } from '../../../../../../../common/latency_aggregation_types';
import { Transaction } from '../../../../../../../typings/es_schemas/ui/transaction';
import { useApmParams } from '../../../../../../hooks/use_apm_params';
import { useAnyOfApmParams } from '../../../../../../hooks/use_apm_params';
import { TransactionDetailLink } from '../../../../../shared/links/apm/transaction_detail_link';
import { ServiceLink } from '../../../../../shared/service_link';
import { StickyProperties } from '../../../../../shared/sticky_properties';
@ -23,9 +24,17 @@ interface Props {
}
export function FlyoutTopLevelProperties({ transaction }: Props) {
const { query } = useApmParams('/services/{serviceName}/transactions/view');
const { query } = useAnyOfApmParams(
'/services/{serviceName}/transactions/view',
'/traces/explorer'
);
const { latencyAggregationType, comparisonEnabled, offset } = query;
const latencyAggregationType =
('latencyAggregationType' in query && query.latencyAggregationType) ||
LatencyAggregationType.avg;
const serviceGroup = ('serviceGroup' in query && query.serviceGroup) || '';
const { comparisonEnabled, offset } = query;
if (!transaction) {
return null;
@ -45,7 +54,7 @@ export function FlyoutTopLevelProperties({ transaction }: Props) {
val: (
<ServiceLink
agentName={transaction.agent.name}
query={{ ...query, environment: nextEnvironment }}
query={{ ...query, serviceGroup, environment: nextEnvironment }}
serviceName={transaction.service.name}
/>
),

View file

@ -18,11 +18,12 @@ import { getNextEnvironmentUrlParam } from '../../../../../../../../common/envir
import { NOT_AVAILABLE_LABEL } from '../../../../../../../../common/i18n';
import { Span } from '../../../../../../../../typings/es_schemas/ui/span';
import { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction';
import { useApmParams } from '../../../../../../../hooks/use_apm_params';
import { useAnyOfApmParams } from '../../../../../../../hooks/use_apm_params';
import { BackendLink } from '../../../../../../shared/backend_link';
import { TransactionDetailLink } from '../../../../../../shared/links/apm/transaction_detail_link';
import { ServiceLink } from '../../../../../../shared/service_link';
import { StickyProperties } from '../../../../../../shared/sticky_properties';
import { LatencyAggregationType } from '../../../../../../../../common/latency_aggregation_types';
interface Props {
span: Span;
@ -30,9 +31,17 @@ interface Props {
}
export function StickySpanProperties({ span, transaction }: Props) {
const { query } = useApmParams('/services/{serviceName}/transactions/view');
const { environment, latencyAggregationType, comparisonEnabled, offset } =
query;
const { query } = useAnyOfApmParams(
'/services/{serviceName}/transactions/view',
'/traces/explorer'
);
const { environment, comparisonEnabled, offset } = query;
const latencyAggregationType =
('latencyAggregationType' in query && query.latencyAggregationType) ||
LatencyAggregationType.avg;
const serviceGroup = ('serviceGroup' in query && query.serviceGroup) || '';
const trackEvent = useUiTracker();
@ -56,6 +65,7 @@ export function StickySpanProperties({ span, transaction }: Props) {
agentName={transaction.agent.name}
query={{
...query,
serviceGroup,
environment: nextEnvironment,
}}
serviceName={transaction.service.name}

View file

@ -24,7 +24,7 @@ import { ColdStartBadge } from './badge/cold_start_badge';
import { IWaterfallSpanOrTransaction } from './waterfall_helpers/waterfall_helpers';
import { FailureBadge } from './failure_badge';
import { useApmRouter } from '../../../../../../hooks/use_apm_router';
import { useApmParams } from '../../../../../../hooks/use_apm_params';
import { useAnyOfApmParams } from '../../../../../../hooks/use_apm_params';
type ItemType = 'transaction' | 'span' | 'error';
@ -258,12 +258,16 @@ function RelatedErrors({
}) {
const apmRouter = useApmRouter();
const theme = useTheme();
const { query } = useApmParams('/services/{serviceName}/transactions/view');
const { query } = useAnyOfApmParams(
'/services/{serviceName}/transactions/view',
'/traces/explorer'
);
const href = apmRouter.link(`/services/{serviceName}/errors`, {
path: { serviceName: item.doc.service.name },
query: {
...query,
serviceGroup: '',
kuery: `${TRACE_ID} : "${item.doc.trace.id}" and ${TRANSACTION_ID} : "${item.doc.transaction?.id}"`,
},
});

View file

@ -17,7 +17,6 @@ import {
simpleTrace,
traceChildStartBeforeParent,
traceWithErrors,
urlParams as testUrlParams,
} from './waterfall_container.stories.data';
import type { ApmPluginContextValue } from '../../../../../context/apm_plugin/apm_plugin_context';
@ -50,48 +49,87 @@ const stories: Meta<Args> = {
};
export default stories;
export const Example: Story<Args> = ({ urlParams, waterfall }) => {
return <WaterfallContainer urlParams={urlParams} waterfall={waterfall} />;
export const Example: Story<Args> = ({
serviceName,
waterfallItemId,
waterfall,
}) => {
return (
<WaterfallContainer
serviceName={serviceName}
waterfallItemId={waterfallItemId}
waterfall={waterfall}
/>
);
};
Example.args = {
urlParams: testUrlParams,
waterfall: getWaterfall(simpleTrace, '975c8d5bfd1dd20b'),
};
export const WithErrors: Story<Args> = ({ urlParams, waterfall }) => {
return <WaterfallContainer urlParams={urlParams} waterfall={waterfall} />;
export const WithErrors: Story<Args> = ({
serviceName,
waterfallItemId,
waterfall,
}) => {
return (
<WaterfallContainer
serviceName={serviceName}
waterfallItemId={waterfallItemId}
waterfall={waterfall}
/>
);
};
WithErrors.args = {
urlParams: testUrlParams,
waterfall: getWaterfall(traceWithErrors, '975c8d5bfd1dd20b'),
};
export const ChildStartsBeforeParent: Story<Args> = ({
urlParams,
serviceName,
waterfallItemId,
waterfall,
}) => {
return <WaterfallContainer urlParams={urlParams} waterfall={waterfall} />;
return (
<WaterfallContainer
serviceName={serviceName}
waterfallItemId={waterfallItemId}
waterfall={waterfall}
/>
);
};
ChildStartsBeforeParent.args = {
urlParams: testUrlParams,
waterfall: getWaterfall(traceChildStartBeforeParent, '975c8d5bfd1dd20b'),
};
export const InferredSpans: Story<Args> = ({ urlParams, waterfall }) => {
return <WaterfallContainer urlParams={urlParams} waterfall={waterfall} />;
export const InferredSpans: Story<Args> = ({
serviceName,
waterfallItemId,
waterfall,
}) => {
return (
<WaterfallContainer
serviceName={serviceName}
waterfallItemId={waterfallItemId}
waterfall={waterfall}
/>
);
};
InferredSpans.args = {
urlParams: testUrlParams,
waterfall: getWaterfall(inferredSpans, 'f2387d37260d00bd'),
};
export const ManyChildrenWithSameLength: Story<Args> = ({
urlParams,
serviceName,
waterfallItemId,
waterfall,
}) => {
return <WaterfallContainer urlParams={urlParams} waterfall={waterfall} />;
return (
<WaterfallContainer
serviceName={serviceName}
waterfallItemId={waterfallItemId}
waterfall={waterfall}
/>
);
};
ManyChildrenWithSameLength.args = {
urlParams: testUrlParams,
waterfall: getWaterfall(manyChildrenWithSameLength, '9a7f717439921d39'),
};

View file

@ -20,6 +20,7 @@ import {
HeaderMenuPortal,
InspectorContextProvider,
} from '@kbn/observability-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { ScrollToTopOnPathChange } from '../app/main/scroll_to_top_on_path_change';
import { AnomalyDetectionJobsContextProvider } from '../../context/anomaly_detection_jobs/anomaly_detection_jobs_context';
import {
@ -39,6 +40,8 @@ import { TrackPageview } from './track_pageview';
import { RedirectWithDefaultEnvironment } from '../shared/redirect_with_default_environment';
import { RedirectWithOffset } from '../shared/redirect_with_offset';
const storage = new Storage(localStorage);
export function ApmAppRoot({
apmPluginContextValue,
pluginsStart,
@ -58,7 +61,7 @@ export function ApmAppRoot({
role="main"
>
<ApmPluginContext.Provider value={apmPluginContextValue}>
<KibanaContextProvider services={{ ...core, ...pluginsStart }}>
<KibanaContextProvider services={{ ...core, ...pluginsStart, storage }}>
<i18nCore.Context>
<TimeRangeIdContextProvider>
<RouterProvider history={history} router={apmRouter as any}>

View file

@ -5,40 +5,49 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { Outlet } from '@kbn/typed-react-router-config';
import { Outlet, Route } from '@kbn/typed-react-router-config';
import * as t from 'io-ts';
import React, { ComponentProps } from 'react';
import { toBooleanRt } from '@kbn/io-ts-utils';
import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values';
import { environmentRt } from '../../../../common/environment_rt';
import { TraceSearchType } from '../../../../common/trace_explorer';
import { BackendDetailOverview } from '../../app/backend_detail_overview';
import { BackendInventory } from '../../app/backend_inventory';
import { Breadcrumb } from '../../app/breadcrumb';
import { ServiceInventory } from '../../app/service_inventory';
import { ServiceMapHome } from '../../app/service_map';
import { TraceOverview } from '../../app/trace_overview';
import { TraceExplorer } from '../../app/trace_explorer';
import { TopTracesOverview } from '../../app/top_traces_overview';
import { ApmMainTemplate } from '../templates/apm_main_template';
import { RedirectToBackendOverviewRouteView } from './redirect_to_backend_overview_route_view';
import { ServiceGroupTemplate } from '../templates/service_group_template';
import { ServiceGroupsRedirect } from '../service_groups_redirect';
import { RedirectTo } from '../redirect_to';
import { offsetRt } from '../../../../common/offset_rt';
import { TransactionTab } from '../../app/transaction_details/waterfall_with_summary/transaction_tabs';
function page<TPath extends string>({
function page<
TPath extends string,
TChildren extends Record<string, Route> | undefined = undefined
>({
path,
element,
children,
title,
showServiceGroupSaveButton = false,
}: {
path: TPath;
element: React.ReactElement<any, any>;
children?: TChildren;
title: string;
showServiceGroupSaveButton?: boolean;
}): Record<
TPath,
{
element: React.ReactElement<any, any>;
}
} & (TChildren extends Record<string, Route> ? { children: TChildren } : {})
> {
return {
[path]: {
@ -52,8 +61,9 @@ function page<TPath extends string>({
</ApmMainTemplate>
</Breadcrumb>
),
children,
},
} as Record<TPath, { element: React.ReactElement<any, any> }>;
} as any;
}
function serviceGroupPage<TPath extends string>({
@ -155,19 +165,58 @@ export const home = {
element: <ServiceInventory />,
serviceGroupContextTab: 'service-inventory',
}),
...page({
path: '/traces',
title: i18n.translate('xpack.apm.views.traceOverview.title', {
defaultMessage: 'Traces',
}),
element: <TraceOverview />,
}),
...serviceGroupPage({
path: '/service-map',
title: ServiceMapTitle,
element: <ServiceMapHome />,
serviceGroupContextTab: 'service-map',
}),
...page({
path: '/traces',
title: i18n.translate('xpack.apm.views.traceOverview.title', {
defaultMessage: 'Traces',
}),
element: (
<TraceOverview>
<Outlet />
</TraceOverview>
),
children: {
'/traces/explorer': {
element: <TraceExplorer />,
params: t.type({
query: t.type({
query: t.string,
type: t.union([
t.literal(TraceSearchType.kql),
t.literal(TraceSearchType.eql),
]),
waterfallItemId: t.string,
traceId: t.string,
transactionId: t.string,
detailTab: t.union([
t.literal(TransactionTab.timeline),
t.literal(TransactionTab.metadata),
t.literal(TransactionTab.logs),
]),
}),
}),
defaults: {
query: {
query: '',
type: TraceSearchType.kql,
waterfallItemId: '',
traceId: '',
transactionId: '',
detailTab: TransactionTab.timeline,
},
},
},
'/traces': {
element: <TopTracesOverview />,
},
},
}),
'/backends': {
element: <Outlet />,
params: t.partial({

View file

@ -0,0 +1,49 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useApmParams } from '../../../hooks/use_apm_params';
import { DatePicker } from '.';
import { useTimeRangeId } from '../../../context/time_range_id/use_time_range_id';
import {
toBoolean,
toNumber,
} from '../../../context/url_params_context/helpers';
export function ApmDatePicker() {
const { query } = useApmParams('/*');
if (!('rangeFrom' in query)) {
throw new Error('range not available in route parameters');
}
const {
rangeFrom,
rangeTo,
refreshPaused: refreshPausedFromUrl = 'true',
refreshInterval: refreshIntervalFromUrl = '0',
} = query;
const refreshPaused = toBoolean(refreshPausedFromUrl);
const refreshInterval = toNumber(refreshIntervalFromUrl);
const { incrementTimeRangeId } = useTimeRangeId();
return (
<DatePicker
rangeFrom={rangeFrom}
rangeTo={rangeTo}
refreshPaused={refreshPaused}
refreshInterval={refreshInterval}
onTimeRangeRefresh={() => {
incrementTimeRangeId();
}}
/>
);
}

View file

@ -0,0 +1,195 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Editor, IEditSession, TokenInfo as AceTokenInfo } from 'brace';
import { Maybe } from '../../../../typings/common';
import { EQLCodeEditorSuggestionType } from './constants';
import { EQLToken } from './tokens';
import {
EQLCodeEditorSuggestion,
EQLCodeEditorSuggestionCallback,
EQLCodeEditorSuggestionRequest,
} from './types';
type TokenInfo = AceTokenInfo & {
type: string;
index: number;
};
export class EQLCodeEditorCompleter {
callback?: EQLCodeEditorSuggestionCallback;
private async getCompletionsAsync(
session: IEditSession,
position: { row: number; column: number },
prefix: string | undefined
): Promise<EQLCodeEditorSuggestion[]> {
const token = session.getTokenAt(
position.row,
position.column
) as Maybe<TokenInfo>;
const tokensInLine = session.getTokens(position.row) as TokenInfo[];
function withWhitespace(
vals: EQLCodeEditorSuggestion[],
options: {
before?: string;
after?: string;
} = {}
) {
const { after = ' ' } = options;
let { before = ' ' } = options;
if (
before &&
(token?.value.match(/^\s+$/) || (token && token.type !== 'text'))
) {
before = before.trimLeft();
}
return vals.map((val) => {
const suggestion = typeof val === 'string' ? { value: val } : val;
const valueAsString = suggestion.value;
return {
...suggestion,
caption: valueAsString,
value: [before, valueAsString, after].join(''),
};
});
}
if (
position.row === 0 &&
(!token || token.index === 0) &&
'sequence by'.includes(prefix || '')
) {
return withWhitespace(['sequence by'], {
before: '',
after: ' ',
});
}
const previousTokens = tokensInLine
.slice(0, token ? tokensInLine.indexOf(token) : tokensInLine.length)
.reverse();
const completedEqlToken = previousTokens.find((t) =>
t.type.startsWith('eql.')
);
switch (completedEqlToken?.type) {
case undefined:
return [
...withWhitespace(['['], { before: '', after: ' ' }),
...(position.row > 2
? withWhitespace(['until'], { before: '', after: ' [ ' })
: []),
];
case EQLToken.Sequence:
return withWhitespace(
await this.getExternalSuggestions({
type: EQLCodeEditorSuggestionType.Field,
}),
{
after: '\n\t[ ',
}
);
case EQLToken.SequenceItemStart:
return withWhitespace(
[
...(await this.getExternalSuggestions({
type: EQLCodeEditorSuggestionType.EventType,
})),
'any',
],
{ after: ' where ' }
);
case EQLToken.EventType:
return withWhitespace(['where']);
case EQLToken.Where:
case EQLToken.LogicalOperator:
return [
...withWhitespace(
await this.getExternalSuggestions({
type: EQLCodeEditorSuggestionType.Field,
})
),
...withWhitespace(['true', 'false'], { after: ' ]\n\t' }),
];
case EQLToken.BoolCondition:
return withWhitespace([']'], { after: '\n\t' });
case EQLToken.Operator:
case EQLToken.InOperator:
const field =
previousTokens?.find((t) => t.type === EQLToken.Field)?.value ?? '';
const hasStartedValueLiteral =
!!prefix?.trim() || token?.value.trim() === '"';
return withWhitespace(
await this.getExternalSuggestions({
type: EQLCodeEditorSuggestionType.Value,
field,
value: prefix ?? '',
}),
{ before: hasStartedValueLiteral ? '' : ' "', after: '" ' }
);
case EQLToken.Value:
return [
...withWhitespace([']'], { after: '\n\t' }),
...withWhitespace(['and', 'or']),
];
}
return [];
}
private async getExternalSuggestions(
request: EQLCodeEditorSuggestionRequest
): Promise<EQLCodeEditorSuggestion[]> {
if (this.callback) {
return this.callback(request);
}
return [];
}
getCompletions(
_: Editor,
session: IEditSession,
position: { row: number; column: number },
prefix: string | undefined,
cb: (err: Error | null, suggestions?: EQLCodeEditorSuggestion[]) => void
) {
this.getCompletionsAsync(session, position, prefix)
.then((suggestions) => {
cb(
null,
suggestions.map((sugg) => {
const suggestion =
typeof sugg === 'string'
? { value: sugg, score: 1000 }
: { score: 1000, ...sugg };
return suggestion;
})
);
})
.catch(cb);
}
setSuggestionCb(cb?: EQLCodeEditorSuggestionCallback) {
this.callback = cb;
}
}

View file

@ -0,0 +1,15 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const EQL_MODE_NAME = 'ace/mode/eql';
export const EQL_THEME_NAME = 'ace/theme/eql';
export enum EQLCodeEditorSuggestionType {
EventType = 'eventType',
Field = 'field',
Value = 'value',
}

View file

@ -0,0 +1,145 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import 'brace/ext/language_tools';
import { acequire } from 'brace';
import { EQLToken } from './tokens';
const TextHighlightRules = acequire(
'ace/mode/text_highlight_rules'
).TextHighlightRules;
export class EQLHighlightRules extends TextHighlightRules {
constructor() {
super();
const fieldNameOrValueRegex = /((?:[^\s]+)|(?:".*?"))/;
const operatorRegex = /(:|==|>|>=|<|<=|!=)/;
const sequenceItemEnd = {
token: EQLToken.SequenceItemEnd,
regex: /(\])/,
next: 'start',
};
this.$rules = {
start: [
{
token: EQLToken.Sequence,
regex: /(sequence by)/,
next: 'field',
},
{
token: EQLToken.SequenceItemStart,
regex: /(\[)/,
next: 'sequence_item',
},
{
token: EQLToken.Until,
regex: /(until)/,
next: 'start',
},
],
field: [
{
token: EQLToken.Field,
regex: fieldNameOrValueRegex,
next: 'start',
},
],
sequence_item: [
{
token: EQLToken.EventType,
regex: fieldNameOrValueRegex,
next: 'where',
},
],
sequence_item_end: [sequenceItemEnd],
where: [
{
token: EQLToken.Where,
regex: /(where)/,
next: 'condition',
},
],
condition: [
{
token: EQLToken.BoolCondition,
regex: /(true|false)/,
next: 'sequence_item_end',
},
{
token: EQLToken.Field,
regex: fieldNameOrValueRegex,
next: 'comparison_operator',
},
],
comparison_operator: [
{
token: EQLToken.Operator,
regex: operatorRegex,
next: 'value_or_value_list',
},
],
value_or_value_list: [
{
token: EQLToken.Value,
regex: /("([^"]+)")|([\d+\.]+)|(true|false|null)/,
next: 'logical_operator_or_sequence_item_end',
},
{
token: EQLToken.InOperator,
regex: /(in)/,
next: 'value_list',
},
],
logical_operator_or_sequence_item_end: [
{
token: EQLToken.LogicalOperator,
regex: /(and|or|not)/,
next: 'condition',
},
sequenceItemEnd,
],
value_list: [
{
token: EQLToken.ValueListStart,
regex: /(\()/,
next: 'value_list_item',
},
],
value_list_item: [
{
token: EQLToken.Value,
regex: fieldNameOrValueRegex,
next: 'comma',
},
],
comma: [
{
token: EQLToken.Comma,
regex: /,/,
next: 'value_list_item_or_end',
},
],
value_list_item_or_end: [
{
token: EQLToken.Value,
regex: fieldNameOrValueRegex,
next: 'comma',
},
{
token: EQLToken.ValueListEnd,
regex: /\)/,
next: 'logical_operator_or_sequence_item_end',
},
],
};
this.normalizeRules();
}
}

View file

@ -0,0 +1,24 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TextMode as TextModeInterface, acequire } from 'brace';
import { EQL_MODE_NAME } from './constants';
import { EQLHighlightRules } from './eql_highlight_rules';
type ITextMode = new () => TextModeInterface;
const TextMode = acequire('ace/mode/text').Mode as ITextMode;
export class EQLMode extends TextMode {
HighlightRules: typeof EQLHighlightRules;
$id: string;
constructor() {
super();
this.$id = EQL_MODE_NAME;
this.HighlightRules = EQLHighlightRules;
}
}

View file

@ -0,0 +1,54 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import 'brace/ext/language_tools';
import { last } from 'lodash';
import React, { useRef } from 'react';
import { EuiCodeEditor } from '@kbn/es-ui-shared-plugin/public';
import { EQLCodeEditorCompleter } from './completer';
import { EQL_THEME_NAME } from './constants';
import { EQLMode } from './eql_mode';
import './theme';
import { EQLCodeEditorProps } from './types';
export function EQLCodeEditor(props: EQLCodeEditorProps) {
const {
showGutter = false,
setOptions,
getSuggestions,
...restProps
} = props;
const completer = useRef(new EQLCodeEditorCompleter());
const eqlMode = useRef(new EQLMode());
completer.current.setSuggestionCb(getSuggestions);
const options = {
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
wrap: true,
...setOptions,
};
return (
<div className="euiTextArea" style={{ maxWidth: 'none' }}>
<EuiCodeEditor
showGutter={showGutter}
mode={eqlMode.current}
theme={last(EQL_THEME_NAME.split('/'))}
setOptions={options}
onAceEditorRef={(editor) => {
if (editor) {
editor.editor.completers = [completer.current];
}
}}
{...restProps}
/>
</div>
);
}

View file

@ -0,0 +1,39 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import { once } from 'lodash';
import React, { useEffect, useState } from 'react';
import { EQLCodeEditorProps } from './types';
const loadEqlCodeEditor = once(() => import('.').then((m) => m.EQLCodeEditor));
type EQLCodeEditorComponentType = Awaited<ReturnType<typeof loadEqlCodeEditor>>;
export function LazilyLoadedEQLCodeEditor(props: EQLCodeEditorProps) {
const [EQLCodeEditor, setEQLCodeEditor] = useState<
EQLCodeEditorComponentType | undefined
>();
useEffect(() => {
loadEqlCodeEditor().then((editor) => {
setEQLCodeEditor(() => {
return editor;
});
});
}, []);
return EQLCodeEditor ? (
<EQLCodeEditor {...props} />
) : (
<EuiFlexGroup alignItems="center" justifyContent="center">
<EuiFlexItem>
<EuiLoadingSpinner size="s" />
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,91 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { euiLightVars as theme } from '@kbn/ui-theme';
import { EQL_THEME_NAME } from './constants';
// @ts-expect-error
ace.define(
EQL_THEME_NAME,
['require', 'exports', 'module', 'ace/lib/dom'],
function (acequire: any, exports: any) {
exports.isDark = false;
exports.cssClass = 'ace-eql';
exports.cssText = `
.ace-eql .ace_scroller {
background-color: transparent;
}
.ace-eql .ace_marker-layer .ace_selection {
background: rgb(181, 213, 255);
}
.ace-eql .ace_placeholder {
color: ${theme.euiTextSubduedColor};
padding: 0;
}
.ace-eql .ace_sequence,
.ace-eql .ace_where,
.ace-eql .ace_until {
color: ${theme.euiColorDarkShade};
}
.ace-eql .ace_sequence_item_start,
.ace-eql .ace_sequence_item_end,
.ace-eql .ace_operator,
.ace-eql .ace_logical_operator {
color: ${theme.euiColorMediumShade};
}
.ace-eql .ace_value,
.ace-eql .ace_bool_condition {
color: ${theme.euiColorAccent};
}
.ace-eql .ace_event_type,
.ace-eql .ace_field {
color: ${theme.euiColorPrimaryText};
}
// .ace-eql .ace_gutter {
// color: #333;
// }
.ace-eql .ace_print-margin {
width: 1px;
background: #e8e8e8;
}
.ace-eql .ace_fold {
background-color: #6B72E6;
}
.ace-eql .ace_cursor {
color: black;
}
.ace-eql .ace_invisible {
color: rgb(191, 191, 191);
}
.ace-eql .ace_marker-layer .ace_selection {
background: rgb(181, 213, 255);
}
.ace-eql.ace_multiselect .ace_selection.ace_start {
box-shadow: 0 0 3px 0px white;
}
.ace-eql .ace_marker-layer .ace_step {
background: rgb(252, 255, 0);
}
.ace-eql .ace_marker-layer .ace_stack {
background: rgb(164, 229, 101);
}
.ace-eql .ace_marker-layer .ace_bracket {
margin: -1px 0 0 -1px;
border: 1px solid rgb(192, 192, 192);
}
.ace-eql .ace_marker-layer .ace_selected-word {
background: rgb(250, 250, 255);
border: 1px solid rgb(200, 200, 250);
}
.ace-eql .ace_indent-guide {
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAE0lEQVQImWP4////f4bLly//BwAmVgd1/w11/gAAAABJRU5ErkJggg==") right repeat-y;
}`;
const dom = acequire('../lib/dom');
dom.importCssString(exports.cssText, exports.cssClass);
}
);

View file

@ -0,0 +1,25 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export enum EQLToken {
Sequence = 'eql.sequence',
SequenceItemStart = 'eql.sequence_item_start',
SequenceItemEnd = 'eql.sequence_item_end',
Until = 'eql.until',
Field = 'eql.field',
EventType = 'eql.event_type',
Where = 'eql.where',
BoolCondition = 'eql.bool_condition',
Operator = 'eql.operator',
Value = 'eql.value',
LogicalOperator = 'eql.logical_operator',
InOperator = 'eql.in_operator',
ValueListStart = 'eql.value_list_start',
ValueListItem = 'eql.value_list_item',
ValueListEnd = 'eql.value_list_end',
Comma = 'eql.comma',
}

View file

@ -0,0 +1,33 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiCodeEditorProps } from '@kbn/es-ui-shared-plugin/public';
import { EQLCodeEditorSuggestionType } from './constants';
export type EQLCodeEditorSuggestion =
| string
| { value: string; score?: number };
export type EQLCodeEditorSuggestionRequest =
| {
type:
| EQLCodeEditorSuggestionType.EventType
| EQLCodeEditorSuggestionType.Field;
}
| { type: EQLCodeEditorSuggestionType.Value; field: string; value: string };
export type EQLCodeEditorSuggestionCallback = (
request: EQLCodeEditorSuggestionRequest
) => Promise<EQLCodeEditorSuggestion[]>;
export type EQLCodeEditorProps = Omit<
EuiCodeEditorProps,
'mode' | 'theme' | 'setOptions'
> & {
getSuggestions?: EQLCodeEditorSuggestionCallback;
setOptions?: EuiCodeEditorProps['setOptions'];
};

View file

@ -12,7 +12,7 @@ import React from 'react';
import { useLocation } from 'react-router-dom';
import rison, { RisonValue } from 'rison-node';
import url from 'url';
import { APM_STATIC_INDEX_PATTERN_ID } from '../../../../../common/index_pattern_constants';
import { APM_STATIC_DATA_VIEW_ID } from '../../../../../common/data_view_constants';
import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context';
import { getTimepickerRisonData } from '../rison_helpers';
@ -46,7 +46,7 @@ export const getDiscoverHref = ({
_g: getTimepickerRisonData(location.search),
_a: {
...query._a,
index: APM_STATIC_INDEX_PATTERN_ID,
index: APM_STATIC_DATA_VIEW_ID,
},
};

View file

@ -13,11 +13,8 @@ import {
EuiSpacer,
} from '@elastic/eui';
import React from 'react';
import { useTimeRangeId } from '../../context/time_range_id/use_time_range_id';
import { toBoolean, toNumber } from '../../context/url_params_context/helpers';
import { useApmParams } from '../../hooks/use_apm_params';
import { useBreakpoints } from '../../hooks/use_breakpoints';
import { DatePicker } from './date_picker';
import { ApmDatePicker } from './date_picker/apm_date_picker';
import { KueryBar } from './kuery_bar';
import { TimeComparison } from './time_comparison';
import { TransactionTypeSelect } from './transaction_type_select';
@ -31,39 +28,6 @@ interface Props {
kueryBarBoolFilter?: QueryDslQueryContainer[];
}
function ApmDatePicker() {
const { query } = useApmParams('/*');
if (!('rangeFrom' in query)) {
throw new Error('range not available in route parameters');
}
const {
rangeFrom,
rangeTo,
refreshPaused: refreshPausedFromUrl = 'true',
refreshInterval: refreshIntervalFromUrl = '0',
} = query;
const refreshPaused = toBoolean(refreshPausedFromUrl);
const refreshInterval = toNumber(refreshIntervalFromUrl);
const { incrementTimeRangeId } = useTimeRangeId();
return (
<DatePicker
rangeFrom={rangeFrom}
rangeTo={rangeTo}
refreshPaused={refreshPaused}
refreshInterval={refreshInterval}
onTimeRangeRefresh={() => {
incrementTimeRangeId();
}}
/>
);
}
export function SearchBar({
hidden = false,
showKueryBar = true,

View file

@ -11,6 +11,8 @@ import type { ObservabilityRuleTypeRegistry } from '@kbn/observability-plugin/pu
import { MapsStartApi } from '@kbn/maps-plugin/public';
import { ObservabilityPublicStart } from '@kbn/observability-plugin/public';
import { Start as InspectorPluginStart } from '@kbn/inspector-plugin/public';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { ApmPluginSetupDeps } from '../../plugin';
import { ConfigSchema } from '../..';
@ -22,6 +24,8 @@ export interface ApmPluginContextValue {
plugins: ApmPluginSetupDeps & { maps?: MapsStartApi };
observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry;
observability: ObservabilityPublicStart;
dataViews: DataViewsPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
}
export const ApmPluginContext = createContext({} as ApmPluginContextValue);

View file

@ -0,0 +1,14 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useRoutePath, PathsOf } from '@kbn/typed-react-router-config';
import { ApmRoutes } from '../components/routing/apm_route_config';
export function useApmRoutePath() {
const path = useRoutePath();
return path as PathsOf<ApmRoutes>;
}

View file

@ -0,0 +1,16 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import useAsync from 'react-use/lib/useAsync';
import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context';
import { APM_STATIC_DATA_VIEW_ID } from '../../common/data_view_constants';
export function useStaticDataView() {
const { dataViews } = useApmPluginContext();
return useAsync(() => dataViews.get(APM_STATIC_DATA_VIEW_ID));
}

View file

@ -0,0 +1,15 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { apmTraceExplorerTab } from '@kbn/observability-plugin/common';
import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context';
export function useTraceExplorerEnabledSetting() {
const { core } = useApmPluginContext();
return core.uiSettings.get<boolean>(apmTraceExplorerTab, false);
}

View file

@ -47,6 +47,7 @@ import type {
import type { SecurityPluginStart } from '@kbn/security-plugin/public';
import { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import { enableServiceGroups } from '@kbn/observability-plugin/public';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { registerApmAlerts } from './components/alerting/register_apm_alerts';
import {
getApmEnrollmentFlyoutData,
@ -88,6 +89,8 @@ export interface ApmPluginStartDeps {
fleet?: FleetStart;
security?: SecurityPluginStart;
spaces?: SpacesPluginStart;
dataViews: DataViewsPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
}
const servicesTitle = i18n.translate('xpack.apm.navigation.servicesTitle', {

View file

@ -36,7 +36,7 @@ export async function callAsyncWithDebug<T>({
requestParams: Record<string, any>;
operationName: string;
isCalledWithInternalUser: boolean; // only allow inspection of queries that were retrieved with credentials of the end user
}) {
}): Promise<T> {
if (!debug) {
return cb();
}

View file

@ -11,10 +11,10 @@ export function cancelEsRequestOnAbort<T extends Promise<any>>(
promise: T,
request: KibanaRequest,
controller: AbortController
) {
): T {
const subscription = request.events.aborted$.subscribe(() => {
controller.abort();
});
return promise.finally(() => subscription.unsubscribe());
return promise.finally(() => subscription.unsubscribe()) as T;
}

View file

@ -6,6 +6,7 @@
*/
import type {
EqlSearchRequest,
TermsEnumRequest,
TermsEnumResponse,
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
@ -30,7 +31,10 @@ import {
getDebugTitle,
} from '../call_async_with_debug';
import { cancelEsRequestOnAbort } from '../cancel_es_request_on_abort';
import { unpackProcessorEvents } from './unpack_processor_events';
import {
unpackProcessorEvents,
processorEventsToIndex,
} from './unpack_processor_events';
export type APMEventESSearchRequest = Omit<ESSearchRequest, 'index'> & {
apm: {
@ -46,6 +50,10 @@ export type APMEventESTermsEnumRequest = Omit<TermsEnumRequest, 'index'> & {
apm: { events: ProcessorEvent[] };
};
export type APMEventEqlSearchRequest = Omit<EqlSearchRequest, 'index'> & {
apm: { events: ProcessorEvent[] };
};
// These keys shoul all be `ProcessorEvent.x`, but until TypeScript 4.2 we're inlining them here.
// See https://github.com/microsoft/TypeScript/issues/37888
type TypeOfProcessorEvent<T extends ProcessorEvent> = {
@ -114,7 +122,7 @@ export class APMEventClient {
this.esClient.search(searchParams, {
signal: controller.signal,
meta: true,
}),
}) as Promise<any>,
this.request,
controller
);
@ -139,6 +147,48 @@ export class APMEventClient {
});
}
async eqlSearch(operationName: string, params: APMEventEqlSearchRequest) {
const requestType = 'eql_search';
const index = processorEventsToIndex(params.apm.events, this.indices);
return callAsyncWithDebug({
cb: () => {
const { apm, ...rest } = params;
const eqlSearchPromise = withApmSpan(operationName, () => {
const controller = new AbortController();
return cancelEsRequestOnAbort(
this.esClient.eql.search(
{
index,
...rest,
},
{ signal: controller.signal, meta: true }
),
this.request,
controller
);
});
return unwrapEsResponse(eqlSearchPromise);
},
getDebugMessage: () => ({
body: getDebugBody({
params,
requestType,
operationName,
}),
title: getDebugTitle(this.request),
}),
isCalledWithInternalUser: false,
debug: this.debug,
request: this.request,
requestType,
operationName,
requestParams: params,
});
}
async termsEnum(
operationName: string,
params: APMEventESTermsEnumRequest

View file

@ -9,7 +9,6 @@ import { uniq, defaultsDeep, cloneDeep } from 'lodash';
import { ESSearchRequest, ESFilter } from '@kbn/core/types/elasticsearch';
import { PROCESSOR_EVENT } from '../../../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../../../common/processor_event';
import { APMEventESSearchRequest, APMEventESTermsEnumRequest } from '.';
import { ApmIndicesConfig } from '../../../../routes/settings/apm_indices/get_apm_indices';
const processorEventIndexMap = {
@ -21,13 +20,24 @@ const processorEventIndexMap = {
[ProcessorEvent.profile]: 'transaction',
} as const;
export function processorEventsToIndex(
events: ProcessorEvent[],
indices: ApmIndicesConfig
) {
return uniq(events.map((event) => indices[processorEventIndexMap[event]]));
}
export function unpackProcessorEvents(
request: APMEventESSearchRequest | APMEventESTermsEnumRequest,
request: {
apm: {
events: ProcessorEvent[];
};
},
indices: ApmIndicesConfig
) {
const { apm, ...params } = request;
const events = uniq(apm.events);
const index = events.map((event) => indices[processorEventIndexMap[event]]);
const index = processorEventsToIndex(events, indices);
const withFilterForProcessorEvent: ESSearchRequest & {
body: { query: { bool: { filter: ESFilter[] } } };

View file

@ -74,7 +74,11 @@ export async function createInternalESClient({
): Promise<ESSearchResponse<TDocument, TSearchRequest>> => {
return callEs(operationName, {
requestType: 'search',
cb: (signal) => asInternalUser.search(params, { signal, meta: true }),
cb: (signal) =>
asInternalUser.search(params, {
signal,
meta: true,
}) as Promise<{ body: any }>,
params,
});
},

View file

@ -6,7 +6,7 @@
*/
import { SavedObjectsErrorHelpers } from '@kbn/core/server';
import { APM_STATIC_INDEX_PATTERN_ID } from '../../../common/index_pattern_constants';
import { APM_STATIC_DATA_VIEW_ID } from '../../../common/data_view_constants';
import { hasHistoricalAgentData } from '../historical_data/has_historical_agent_data';
import { Setup } from '../../lib/helpers/setup_request';
import { APMRouteHandlerResources } from '../typings';
@ -55,7 +55,7 @@ export async function createStaticDataView({
'index-pattern',
getApmDataViewAttributes(apmDataViewTitle),
{
id: APM_STATIC_INDEX_PATTERN_ID,
id: APM_STATIC_DATA_VIEW_ID,
overwrite: forceOverwrite,
namespace: spaceId,
}
@ -86,7 +86,7 @@ async function getForceOverwrite({
const existingDataView =
await savedObjectsClient.get<ApmDataViewAttributes>(
'index-pattern',
APM_STATIC_INDEX_PATTERN_ID
APM_STATIC_DATA_VIEW_ID
);
// if the existing data view does not matches the new one, force an update

View file

@ -115,7 +115,7 @@ export function transformServiceMapResponses(response: ServiceMapResponse) {
? anomalies.serviceAnomalies.find(
(item) => item.serviceName === serviceName
)
: null;
: undefined;
if (matchedServiceNodes.length) {
return {
@ -158,9 +158,16 @@ export function transformServiceMapResponses(response: ServiceMapResponse) {
const sourceData = getConnectionNode(connection.source);
const targetData = getConnectionNode(connection.destination);
const label =
sourceData[SERVICE_NAME] +
' to ' +
(targetData[SERVICE_NAME] ||
targetData[SPAN_DESTINATION_SERVICE_RESOURCE]);
return {
source: sourceData.id,
target: targetData.id,
label,
id: getConnectionId({ source: sourceData, destination: targetData }),
sourceData,
targetData,

View file

@ -0,0 +1,168 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
rangeQuery,
kqlQuery,
termsQuery,
} from '@kbn/observability-plugin/server';
import { Environment } from '../../../common/environment_rt';
import { Setup } from '../../lib/helpers/setup_request';
import { TraceSearchType } from '../../../common/trace_explorer';
import { ProcessorEvent } from '../../../common/processor_event';
import { environmentQuery } from '../../../common/utils/environment_query';
import {
PARENT_ID,
PROCESSOR_EVENT,
TRACE_ID,
TRANSACTION_ID,
TRANSACTION_SAMPLED,
} from '../../../common/elasticsearch_fieldnames';
import { asMutableArray } from '../../../common/utils/as_mutable_array';
export async function getTraceSamplesByQuery({
setup,
start,
end,
environment,
query,
type,
}: {
setup: Setup;
start: number;
end: number;
environment: Environment;
query: string;
type: TraceSearchType;
}) {
const size = 500;
let traceIds: string[] = [];
if (type === TraceSearchType.kql) {
traceIds =
(
await setup.apmEventClient.search('get_trace_ids_by_kql_query', {
apm: {
events: [
ProcessorEvent.transaction,
ProcessorEvent.span,
ProcessorEvent.error,
],
},
body: {
size: 0,
query: {
bool: {
filter: [
...rangeQuery(start, end),
...environmentQuery(environment),
...kqlQuery(query),
],
},
},
aggs: {
traceId: {
terms: {
field: TRACE_ID,
execution_hint: 'map',
size,
},
},
},
},
})
).aggregations?.traceId.buckets.map((bucket) => bucket.key as string) ??
[];
} else if (type === TraceSearchType.eql) {
traceIds =
(
await setup.apmEventClient.eqlSearch('get_trace_ids_by_eql_query', {
apm: {
events: [
ProcessorEvent.transaction,
ProcessorEvent.span,
ProcessorEvent.error,
],
},
body: {
size: 1000,
filter: {
bool: {
filter: [
...rangeQuery(start, end),
...environmentQuery(environment),
],
},
},
event_category_field: PROCESSOR_EVENT,
query,
},
filter_path: 'hits.sequences.events._source.trace.id',
})
).hits?.sequences?.flatMap((sequence) =>
sequence.events.map(
(event) => (event._source as { trace: { id: string } }).trace.id
)
) ?? [];
}
if (!traceIds.length) {
return [];
}
const traceSamplesResponse = await setup.apmEventClient.search(
'get_trace_samples_by_trace_ids',
{
apm: {
events: [ProcessorEvent.transaction],
},
body: {
size: 0,
query: {
bool: {
filter: [
{
term: {
[TRANSACTION_SAMPLED]: true,
},
},
...termsQuery(TRACE_ID, ...traceIds),
...rangeQuery(start, end),
],
must_not: [{ exists: { field: PARENT_ID } }],
},
},
aggs: {
transactionId: {
terms: {
field: TRANSACTION_ID,
size,
},
aggs: {
latest: {
top_metrics: {
metrics: asMutableArray([{ field: TRACE_ID }] as const),
size: 1,
sort: {
'@timestamp': 'desc' as const,
},
},
},
},
},
},
},
}
);
return (
traceSamplesResponse.aggregations?.transactionId.buckets.map((bucket) => ({
traceId: bucket.latest.top[0].metrics['trace.id'] as string,
transactionId: bucket.key as string,
})) ?? []
);
}

View file

@ -6,9 +6,9 @@
*/
import * as t from 'io-ts';
import { TraceSearchType } from '../../../common/trace_explorer';
import { setupRequest } from '../../lib/helpers/setup_request';
import { getTraceItems } from './get_trace_items';
import { getTopTracesPrimaryStats } from './get_top_traces_primary_stats';
import { getSearchAggregatedTransactions } from '../../lib/helpers/transactions';
import { createApmServerRoute } from '../apm_routes/create_apm_server_route';
import {
environmentRt,
@ -16,9 +16,11 @@ import {
probabilityRt,
rangeRt,
} from '../default_api_types';
import { getSearchAggregatedTransactions } from '../../lib/helpers/transactions';
import { getRootTransactionByTraceId } from '../transactions/get_transaction_by_trace';
import { getTransaction } from '../transactions/get_transaction';
import { getRootTransactionByTraceId } from '../transactions/get_transaction_by_trace';
import { getTopTracesPrimaryStats } from './get_top_traces_primary_stats';
import { getTraceItems } from './get_trace_items';
import { getTraceSamplesByQuery } from './get_trace_samples_by_query';
const tracesRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/traces',
@ -135,9 +137,50 @@ const transactionByIdRoute = createApmServerRoute({
},
});
const findTracesRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/traces/find',
params: t.type({
query: t.intersection([
rangeRt,
environmentRt,
t.type({
query: t.string,
type: t.union([
t.literal(TraceSearchType.kql),
t.literal(TraceSearchType.eql),
]),
}),
]),
}),
options: {
tags: ['access:apm'],
},
handler: async (
resources
): Promise<{
samples: Array<{ traceId: string; transactionId: string }>;
}> => {
const { start, end, environment, query, type } = resources.params.query;
const setup = await setupRequest(resources);
return {
samples: await getTraceSamplesByQuery({
setup,
start,
end,
environment,
query,
type,
}),
};
},
});
export const traceRouteRepository = {
...tracesByIdRoute,
...tracesRoute,
...rootTransactionByTraceIdRoute,
...transactionByIdRoute,
...findTracesRoute,
};

View file

@ -13,7 +13,7 @@ import {
} from '@kbn/home-plugin/server';
import { CloudSetup } from '@kbn/cloud-plugin/server';
import { APMConfig } from '..';
import { APM_STATIC_INDEX_PATTERN_ID } from '../../common/index_pattern_constants';
import { APM_STATIC_DATA_VIEW_ID } from '../../common/data_view_constants';
import { getApmDataViewAttributes } from '../routes/data_view/get_apm_data_view_attributes';
import { getApmDataViewTitle } from '../routes/data_view/get_apm_data_view_title';
import { ApmIndicesConfig } from '../routes/settings/apm_indices/get_apm_indices';
@ -42,7 +42,7 @@ export const tutorialProvider =
const dataViewTitle = getApmDataViewTitle(apmIndices);
const savedObjects = [
{
id: APM_STATIC_INDEX_PATTERN_ID,
id: APM_STATIC_DATA_VIEW_ID,
attributes: getApmDataViewAttributes(dataViewTitle),
type: 'index-pattern',
},

View file

@ -17,6 +17,7 @@ export {
defaultApmServiceEnvironment,
apmServiceInventoryOptimizedSorting,
apmProgressiveLoading,
apmTraceExplorerTab,
} from './ui_settings_keys';
export {

View file

@ -15,3 +15,4 @@ export const apmProgressiveLoading = 'observability:apmProgressiveLoading';
export const enableServiceGroups = 'observability:enableServiceGroups';
export const apmServiceInventoryOptimizedSorting =
'observability:apmServiceInventoryOptimizedSorting';
export const apmTraceExplorerTab = 'observability:apmTraceExplorerTab';

View file

@ -71,7 +71,7 @@ function getStats({
},
};
if (esResponse?.hits) {
if (esResponse?.hits?.hits) {
stats.hits = {
label: i18n.translate('xpack.observability.inspector.stats.hitsLabel', {
defaultMessage: 'Hits',

View file

@ -19,6 +19,7 @@ import {
enableServiceGroups,
apmServiceInventoryOptimizedSorting,
enableNewSyntheticsView,
apmTraceExplorerTab,
} from '../common/ui_settings_keys';
const technicalPreviewLabel = i18n.translate(
@ -187,4 +188,19 @@ export const uiSettings: Record<string, UiSettingsParams<boolean | number | stri
requiresPageReload: false,
type: 'boolean',
},
[apmTraceExplorerTab]: {
category: [observabilityFeatureId],
name: i18n.translate('xpack.observability.apmTraceExplorerTab', {
defaultMessage: 'APM Trace Explorer',
}),
description: i18n.translate('xpack.observability.apmTraceExplorerTabDescription', {
defaultMessage:
'{technicalPreviewLabel} Enable the APM Trace Explorer feature, that allows you to search and inspect traces with KQL or EQL',
values: { technicalPreviewLabel: `<em>[${technicalPreviewLabel}]</em>` },
}),
schema: schema.boolean(),
value: false,
requiresPageReload: true,
type: 'boolean',
},
};

View file

@ -7,7 +7,7 @@
import { apm, ApmSynthtraceEsClient, timerange } from '@elastic/apm-synthtrace';
import expect from '@kbn/expect';
import { APM_STATIC_INDEX_PATTERN_ID } from '@kbn/apm-plugin/common/index_pattern_constants';
import { APM_STATIC_DATA_VIEW_ID } from '@kbn/apm-plugin/common/data_view_constants';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { SupertestReturnType } from '../../common/apm_api_supertest';
@ -26,13 +26,13 @@ export default function ApiTest({ getService }: FtrProviderContext) {
function deleteDataView() {
// return supertest.delete('/api/saved_objects/<type>/<id>').set('kbn-xsrf', 'foo').expect(200)
return supertest
.delete(`/api/saved_objects/index-pattern/${APM_STATIC_INDEX_PATTERN_ID}`)
.delete(`/api/saved_objects/index-pattern/${APM_STATIC_DATA_VIEW_ID}`)
.set('kbn-xsrf', 'foo')
.expect(200);
}
function getDataView() {
return supertest.get(`/api/saved_objects/index-pattern/${APM_STATIC_INDEX_PATTERN_ID}`);
return supertest.get(`/api/saved_objects/index-pattern/${APM_STATIC_DATA_VIEW_ID}`);
}
function getDataViewSuggestions(field: string) {

View file

@ -0,0 +1,307 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { apm, timerange } from '@elastic/apm-synthtrace';
import expect from '@kbn/expect';
import { TraceSearchType } from '@kbn/apm-plugin/common/trace_explorer';
import { Environment } from '@kbn/apm-plugin/common/environment_rt';
import { ENVIRONMENT_ALL } from '@kbn/apm-plugin/common/environment_filter_values';
import { sortBy } from 'lodash';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { ApmApiError } from '../../common/apm_api_supertest';
type Instance = ReturnType<ReturnType<typeof apm.service>['instance']>;
type Transaction = ReturnType<Instance['transaction']>;
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const synthtraceEsClient = getService('synthtraceEsClient');
const start = new Date('2022-01-01T00:00:00.000Z').getTime();
const end = new Date('2022-01-01T00:15:00.000Z').getTime() - 1;
// for EQL sequences to work, events need a slight time offset,
// as ES will sort based on @timestamp. to acommodate this offset
// we also add a little bit of a buffer to the requested time range
const endWithOffset = end + 100000;
async function fetchTraceSamples({
query,
type,
environment,
}: {
query: string;
type: TraceSearchType;
environment: Environment;
}) {
return apmApiClient.readUser({
endpoint: `GET /internal/apm/traces/find`,
params: {
query: {
query,
type,
start: new Date(start).toISOString(),
end: new Date(endWithOffset).toISOString(),
environment,
},
},
});
}
function fetchTraces(samples: Array<{ traceId: string; transactionId: string }>) {
if (!samples.length) {
return [];
}
return Promise.all(
samples.map(async ({ traceId }) => {
const response = await apmApiClient.readUser({
endpoint: `GET /internal/apm/traces/{traceId}`,
params: {
path: { traceId },
query: {
start: new Date(start).toISOString(),
end: new Date(endWithOffset).toISOString(),
},
},
});
return response.body.traceDocs;
})
);
}
registry.when(
'Find traces when traces do not exist',
{ config: 'basic', archives: ['apm_mappings_only_8.0.0'] },
() => {
it('handles empty state', async () => {
const response = await fetchTraceSamples({
query: '',
type: TraceSearchType.kql,
environment: ENVIRONMENT_ALL.value,
});
expect(response.status).to.be(200);
expect(response.body).to.eql({
samples: [],
});
});
}
);
registry.when(
'Find traces when traces exist',
{ config: 'basic', archives: ['apm_mappings_only_8.0.0'] },
() => {
before(() => {
const java = apm.service('java', 'production', 'java').instance('java');
const node = apm.service('node', 'development', 'nodejs').instance('node');
const python = apm.service('python', 'production', 'python').instance('python');
function generateTrace(
timestamp: number,
order: Instance[],
db?: 'elasticsearch' | 'redis'
) {
return order
.concat()
.reverse()
.reduce<Transaction | undefined>((prev, instance, index) => {
const invertedIndex = order.length - index - 1;
const duration = 50;
const time = timestamp + invertedIndex * 10;
const transaction: Transaction = instance
.transaction(`GET /${instance.fields['service.name']!}/api`)
.timestamp(time)
.duration(duration);
if (prev) {
const next = order[invertedIndex + 1].fields['service.name']!;
transaction.children(
instance
.span(`GET ${next}/api`, 'external', 'http')
.destination(next)
.duration(duration)
.timestamp(time + 1)
.children(prev)
);
} else if (db) {
transaction.children(
instance
.span(db, 'db', db)
.destination(db)
.duration(duration)
.timestamp(time + 1)
);
}
return transaction;
}, undefined)!;
}
return synthtraceEsClient.index(
timerange(start, end)
.interval('15m')
.rate(1)
.generator((timestamp) => {
return [
generateTrace(timestamp, [java, node]),
generateTrace(timestamp, [node, java], 'redis'),
generateTrace(timestamp, [python], 'redis'),
generateTrace(timestamp, [python, node, java], 'elasticsearch'),
generateTrace(timestamp, [java, python, node]),
];
})
);
});
describe('when using KQL', () => {
describe('and the query is empty', () => {
it('returns all trace samples', async () => {
const {
body: { samples },
} = await fetchTraceSamples({
query: '',
type: TraceSearchType.kql,
environment: 'ENVIRONMENT_ALL',
});
expect(samples.length).to.eql(5);
});
});
describe('and query is set', () => {
it('returns the relevant traces', async () => {
const {
body: { samples },
} = await fetchTraceSamples({
query: 'span.destination.service.resource:elasticsearch',
type: TraceSearchType.kql,
environment: 'ENVIRONMENT_ALL',
});
expect(samples.length).to.eql(1);
});
});
});
describe('when using EQL', () => {
describe('and the query is invalid', () => {
it.skip('returns a 400', async function () {
try {
await fetchTraceSamples({
query: '',
type: TraceSearchType.eql,
environment: 'ENVIRONMENT_ALL',
});
this.fail();
} catch (error: unknown) {
const apiError = error as ApmApiError;
expect(apiError.res.status).to.eql(400);
}
});
});
describe('and the query is set', () => {
it('returns the correct trace samples for transaction sequences', async () => {
const {
body: { samples },
} = await fetchTraceSamples({
query: `sequence by trace.id
[ transaction where service.name == "java" ]
[ transaction where service.name == "node" ]`,
type: TraceSearchType.eql,
environment: 'ENVIRONMENT_ALL',
});
const traces = await fetchTraces(samples);
expect(traces.length).to.eql(2);
const mapped = traces.map((traceDocs) => {
return sortBy(traceDocs, '@timestamp')
.filter((doc) => doc.processor.event === 'transaction')
.map((doc) => doc.service.name);
});
expect(mapped).to.eql([
['java', 'node'],
['java', 'python', 'node'],
]);
});
});
it('returns the correct trace samples for join sequences', async () => {
const {
body: { samples },
} = await fetchTraceSamples({
query: `sequence by trace.id
[ span where service.name == "java" ] by span.id
[ transaction where service.name == "python" ] by parent.id`,
type: TraceSearchType.eql,
environment: 'ENVIRONMENT_ALL',
});
const traces = await fetchTraces(samples);
expect(traces.length).to.eql(1);
const mapped = traces.map((traceDocs) => {
return sortBy(traceDocs, '@timestamp')
.filter((doc) => doc.processor.event === 'transaction')
.map((doc) => doc.service.name);
});
expect(mapped).to.eql([['java', 'python', 'node']]);
});
it('returns the correct trace samples for exit spans', async () => {
const {
body: { samples },
} = await fetchTraceSamples({
query: `sequence by trace.id
[ transaction where service.name == "python" ]
[ span where span.destination.service.resource == "redis" ]`,
type: TraceSearchType.eql,
environment: 'ENVIRONMENT_ALL',
});
const traces = await fetchTraces(samples);
expect(traces.length).to.eql(1);
const mapped = traces.map((traceDocs) => {
return sortBy(traceDocs, '@timestamp')
.filter(
(doc) => doc.processor.event === 'transaction' || doc.processor.event === 'span'
)
.map((doc) => {
if (doc.span && 'destination' in doc.span) {
return doc.span.destination!.service.resource;
}
return doc.service.name;
});
});
expect(mapped).to.eql([['python', 'redis']]);
});
});
after(() => synthtraceEsClient.clean());
}
);
}