mirror of
https://github.com/elastic/kibana.git
synced 2025-04-18 23:21:39 -04:00
[Discover][APM] Add duration section to overview tab in flyout and include basic duration information (#216291)
## Summary Closes https://github.com/elastic/kibana/issues/214446 This PR introduces a new section in the span/transaction overview flyout that will display the duration information along with a latency chart. For now, only the duration data is included. |Scenario|| |-|-| |Span w/ `transaction.id`|| |Span w/o `transaction.id`|| |Transaction w/ `parent.id`|| |Transaction w/o `parent.id` (root transaction)|| Given that both spans and transactions require retrieving data from their respective parents, a loader has been added to reflect the ongoing data-fetching process:  ## Tech details The UI for displaying the duration and its percentage relative to the parent is already in use in APM for spans and transactions, so the logic is consistent with that. To avoid duplicating components and formatters, as seen in previous PRs for Traces in Discover, a new `Duration` component has been created in a newly created `kbn-apm-ui-shared` package. This component will be used in Discover and [later](https://github.com/elastic/kibana/issues/211781) in APM as well. ## How to test - Enable the discover profiles by adding this to the` kibana.yml `file: ```discover.experimental.enabledProfiles: - observability-traces-data-source-profile - observability-traces-transaction-document-profile - observability-traces-span-document-profile ``` - Open Discover and select or create a data view that includes any APM traces index (`traces-*`), or query them using ES|QL. - Apply a filter for `data_stream.type:"traces"` to ensure only trace documents are retrieved. - Open the flyout. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Carlos Crespo <crespocarlos@users.noreply.github.com>
This commit is contained in:
parent
968dd5554e
commit
5c950b4f3e
43 changed files with 876 additions and 133 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -411,6 +411,7 @@ src/platform/packages/shared/kbn-analytics @elastic/kibana-core
|
|||
src/platform/packages/shared/kbn-apm-data-view @elastic/obs-ux-infra_services-team
|
||||
src/platform/packages/shared/kbn-apm-synthtrace @elastic/obs-ux-infra_services-team @elastic/obs-ux-logs-team
|
||||
src/platform/packages/shared/kbn-apm-synthtrace-client @elastic/obs-ux-infra_services-team @elastic/obs-ux-logs-team
|
||||
src/platform/packages/shared/kbn-apm-ui-shared @elastic/obs-ux-infra_services-team
|
||||
src/platform/packages/shared/kbn-apm-utils @elastic/obs-ux-infra_services-team
|
||||
src/platform/packages/shared/kbn-avc-banner @elastic/security-defend-workflows
|
||||
src/platform/packages/shared/kbn-axe-config @elastic/appex-qa
|
||||
|
|
|
@ -200,6 +200,7 @@
|
|||
"@kbn/apm-plugin": "link:x-pack/solutions/observability/plugins/apm",
|
||||
"@kbn/apm-sources-access-plugin": "link:x-pack/platform/plugins/shared/apm_sources_access",
|
||||
"@kbn/apm-types": "link:x-pack/platform/packages/shared/kbn-apm-types",
|
||||
"@kbn/apm-ui-shared": "link:src/platform/packages/shared/kbn-apm-ui-shared",
|
||||
"@kbn/apm-utils": "link:src/platform/packages/shared/kbn-apm-utils",
|
||||
"@kbn/app-link-test-plugin": "link:src/platform/test/plugin_functional/plugins/app_link_test",
|
||||
"@kbn/application-usage-test-plugin": "link:x-pack/test/usage_collection/plugins/application_usage_test",
|
||||
|
|
19
src/platform/packages/shared/kbn-apm-ui-shared/README.mdx
Normal file
19
src/platform/packages/shared/kbn-apm-ui-shared/README.mdx
Normal file
|
@ -0,0 +1,19 @@
|
|||
# @kbn/apm-ui-shared
|
||||
|
||||
## Components
|
||||
|
||||
| Name | Description |
|
||||
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Duration | Displays the duration with automatic unit formatting and the option to show the percentage of the duration relative to a parent (trace or transaction). |
|
||||
|
||||
## Utils
|
||||
|
||||
### Formatters
|
||||
|
||||
| Name | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| asDuration | Format duration value to the best unit for display. |
|
||||
| asDecimal | Format a number to a decimal with a fixed number of decimal places. |
|
||||
| asInteger | Format a number to an integer. |
|
||||
| asDecimalOrInteger | Format a number to a decimal with a fixed number of decimal places if it has a decimal part, otherwise format it to an integer. |
|
||||
| asPercent | Format a number to a percentage with a fixed number of decimal places. |
|
10
src/platform/packages/shared/kbn-apm-ui-shared/index.ts
Normal file
10
src/platform/packages/shared/kbn-apm-ui-shared/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export * from './src/components/duration';
|
|
@ -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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../../../../..',
|
||||
roots: ['<rootDir>/src/platform/packages/shared/kbn-apm-ui-shared'],
|
||||
setupFilesAfterEnv: ['<rootDir>/src/platform/packages/shared/kbn-apm-ui-shared/setup_tests.ts'],
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"type": "shared-browser",
|
||||
"id": "@kbn/apm-ui-shared",
|
||||
"owner": [
|
||||
"@elastic/obs-ux-infra_services-team"
|
||||
],
|
||||
"group": "platform",
|
||||
"visibility": "shared"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/apm-ui-shared",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import '@testing-library/jest-dom';
|
|
@ -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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Duration, DurationProps } from '.';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
describe('Duration', () => {
|
||||
const duration = 10;
|
||||
const parentDuration = 20;
|
||||
const expectedDurationText = `${duration} μs`;
|
||||
const getExpectedParentDurationText = (parentType: string) => `(50% of ${parentType})`;
|
||||
const loadingDataTestSubj = 'DurationLoadingSpinner';
|
||||
|
||||
describe('when there is NOT parent data', () => {
|
||||
it('should render duration with the right format', () => {
|
||||
render(<Duration duration={duration} />);
|
||||
expect(screen.getByText(expectedDurationText)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there is parent data', () => {
|
||||
describe('and the loading is set to true', () => {
|
||||
const parentWithLoading: DurationProps['parent'] = {
|
||||
duration: parentDuration,
|
||||
type: 'trace',
|
||||
loading: true,
|
||||
};
|
||||
|
||||
it('should render the duration and the loader but not the parent duration', () => {
|
||||
render(<Duration duration={duration} parent={parentWithLoading} />);
|
||||
expect(screen.getByText(expectedDurationText)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(loadingDataTestSubj)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('and the loading is set to false', () => {
|
||||
const parentWithLoading: DurationProps['parent'] = {
|
||||
duration: parentDuration,
|
||||
type: 'trace',
|
||||
loading: false,
|
||||
};
|
||||
|
||||
it('should render the duration and the parent duration but not the loader', () => {
|
||||
render(<Duration duration={duration} parent={parentWithLoading} />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
`${expectedDurationText} ${getExpectedParentDurationText(parentWithLoading.type)}`
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(loadingDataTestSubj)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiLoadingSpinner, EuiText } from '@elastic/eui';
|
||||
import { asDuration } from '../../utils';
|
||||
import { PercentOfParent } from './percent_of_parent';
|
||||
|
||||
export interface DurationProps {
|
||||
duration: number;
|
||||
parent?: {
|
||||
duration?: number;
|
||||
type: 'trace' | 'transaction';
|
||||
loading: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function Duration({ duration, parent }: DurationProps) {
|
||||
if (!parent) {
|
||||
<EuiText size="xs">{asDuration(duration)}</EuiText>;
|
||||
}
|
||||
return (
|
||||
<EuiText size="xs">
|
||||
{asDuration(duration)}
|
||||
{parent?.loading && <EuiLoadingSpinner data-test-subj="DurationLoadingSpinner" />}
|
||||
{!parent?.loading && parent?.duration && (
|
||||
<PercentOfParent
|
||||
duration={duration}
|
||||
totalDuration={parent?.duration}
|
||||
parentType={parent?.type}
|
||||
/>
|
||||
)}
|
||||
</EuiText>
|
||||
);
|
||||
}
|
|
@ -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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiToolTip } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { asPercent } from '../../utils';
|
||||
|
||||
interface PercentOfParentProps {
|
||||
duration: number;
|
||||
totalDuration?: number;
|
||||
parentType: 'trace' | 'transaction';
|
||||
}
|
||||
|
||||
export function PercentOfParent({ duration, totalDuration, parentType }: PercentOfParentProps) {
|
||||
totalDuration = totalDuration || duration;
|
||||
const isOver100 = duration > totalDuration;
|
||||
const percentOfParent = isOver100 ? '>100%' : asPercent(duration, totalDuration, '');
|
||||
|
||||
const percentOfParentText = i18n.translate('apmUiShared.duration.percentOfParent', {
|
||||
defaultMessage:
|
||||
'({value} of {parentType, select, transaction {transaction} trace {trace} other {unknown parentType}})',
|
||||
values: { value: percentOfParent, parentType },
|
||||
});
|
||||
|
||||
const childType = parentType === 'trace' ? 'transaction' : 'span';
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOver100 ? (
|
||||
<EuiToolTip
|
||||
content={i18n.translate('apmUiShared.duration.percentOfTraceLabelExplanation', {
|
||||
defaultMessage:
|
||||
'The % of {parentType, select, transaction {transaction} trace {trace} other {unknown parentType} } exceeds 100% because this {childType, select, span {span} transaction {transaction} other {unknown childType} } takes longer than the root transaction.',
|
||||
values: {
|
||||
parentType,
|
||||
childType,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<>{percentOfParentText}</>
|
||||
</EuiToolTip>
|
||||
) : (
|
||||
percentOfParentText
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -47,7 +47,7 @@ function getUnitLabelAndConvertedValue(
|
|||
switch (unitKey) {
|
||||
case 'hours': {
|
||||
return {
|
||||
unitLabel: i18n.translate('unifiedDocViewer.formatters.duration.hoursTimeUnitLabel', {
|
||||
unitLabel: i18n.translate('apmUiShared.formatters.duration.hoursTimeUnitLabel', {
|
||||
defaultMessage: 'h',
|
||||
}),
|
||||
convertedValue: asDecimalOrInteger(moment.duration(ms).asHours(), threshold),
|
||||
|
@ -55,7 +55,7 @@ function getUnitLabelAndConvertedValue(
|
|||
}
|
||||
case 'minutes': {
|
||||
return {
|
||||
unitLabel: i18n.translate('unifiedDocViewer.formatters.duration.minutesTimeUnitLabel', {
|
||||
unitLabel: i18n.translate('apmUiShared.formatters.duration.minutesTimeUnitLabel', {
|
||||
defaultMessage: 'min',
|
||||
}),
|
||||
convertedValue: asDecimalOrInteger(moment.duration(ms).asMinutes(), threshold),
|
||||
|
@ -63,7 +63,7 @@ function getUnitLabelAndConvertedValue(
|
|||
}
|
||||
case 'seconds': {
|
||||
return {
|
||||
unitLabel: i18n.translate('unifiedDocViewer.formatters.duration.secondsTimeUnitLabel', {
|
||||
unitLabel: i18n.translate('apmUiShared.formatters.duration.secondsTimeUnitLabel', {
|
||||
defaultMessage: 's',
|
||||
}),
|
||||
convertedValue: asDecimalOrInteger(moment.duration(ms).asSeconds(), threshold),
|
||||
|
@ -71,7 +71,7 @@ function getUnitLabelAndConvertedValue(
|
|||
}
|
||||
case 'milliseconds': {
|
||||
return {
|
||||
unitLabel: i18n.translate('unifiedDocViewer.formatters.duration.millisTimeUnitLabel', {
|
||||
unitLabel: i18n.translate('apmUiShared.formatters.duration.millisTimeUnitLabel', {
|
||||
defaultMessage: 'ms',
|
||||
}),
|
||||
convertedValue: asDecimalOrInteger(moment.duration(ms).asMilliseconds(), threshold),
|
||||
|
@ -79,7 +79,7 @@ function getUnitLabelAndConvertedValue(
|
|||
}
|
||||
case 'microseconds': {
|
||||
return {
|
||||
unitLabel: i18n.translate('unifiedDocViewer.formatters.duration.microsTimeUnitLabel', {
|
||||
unitLabel: i18n.translate('apmUiShared.formatters.duration.microsTimeUnitLabel', {
|
||||
defaultMessage: 'μs',
|
||||
}),
|
||||
convertedValue: asInteger(value),
|
|
@ -7,7 +7,7 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { asDecimal, asInteger, asDecimalOrInteger } from './numeric';
|
||||
import { asDecimal, asInteger, asDecimalOrInteger, asPercent } from './numeric';
|
||||
|
||||
describe('formatters', () => {
|
||||
describe('asDecimal', () => {
|
||||
|
@ -107,4 +107,31 @@ describe('formatters', () => {
|
|||
expect(asDecimalOrInteger(undefined)).toEqual('N/A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('asPercent', () => {
|
||||
it('formats as integer when number is above 10', () => {
|
||||
expect(asPercent(3725, 10000, 'n/a')).toEqual('37%');
|
||||
});
|
||||
|
||||
it('adds a decimal when value is below 10', () => {
|
||||
expect(asPercent(0.092, 1)).toEqual('9.2%');
|
||||
});
|
||||
|
||||
it('formats when numerator is 0', () => {
|
||||
expect(asPercent(0, 1, 'n/a')).toEqual('0%');
|
||||
});
|
||||
|
||||
it('returns fallback when denominator is undefined', () => {
|
||||
expect(asPercent(3725, undefined, 'n/a')).toEqual('n/a');
|
||||
});
|
||||
|
||||
it('returns fallback when denominator is 0 ', () => {
|
||||
expect(asPercent(3725, 0, 'n/a')).toEqual('n/a');
|
||||
});
|
||||
|
||||
it('returns fallback when numerator or denominator is NaN', () => {
|
||||
expect(asPercent(3725, NaN, 'n/a')).toEqual('n/a');
|
||||
expect(asPercent(NaN, 10000, 'n/a')).toEqual('n/a');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -13,7 +13,7 @@ import numeral from '@elastic/numeral';
|
|||
import { Maybe } from '../../typings';
|
||||
|
||||
export const NOT_AVAILABLE_LABEL = i18n.translate(
|
||||
'unifiedDocViewer.formatters.numeric.notAvailableLabel',
|
||||
'apmUiShared.formatters.numeric.notAvailableLabel',
|
||||
{
|
||||
defaultMessage: 'N/A',
|
||||
}
|
||||
|
@ -45,3 +45,21 @@ export function asDecimalOrInteger(value: Maybe<number>, threshold = 10) {
|
|||
}
|
||||
return asDecimal(value);
|
||||
}
|
||||
|
||||
export function asPercent(
|
||||
numerator: Maybe<number>,
|
||||
denominator: number | undefined,
|
||||
fallbackResult = NOT_AVAILABLE_LABEL
|
||||
) {
|
||||
if (numerator === null || numerator === undefined || !denominator || !isFinite(numerator)) {
|
||||
return fallbackResult;
|
||||
}
|
||||
|
||||
const decimal = numerator / denominator;
|
||||
|
||||
if (Math.abs(decimal) >= 0.1 || decimal === 0) {
|
||||
return numeral(decimal).format('0%');
|
||||
}
|
||||
|
||||
return numeral(decimal).format('0.0%');
|
||||
}
|
12
src/platform/packages/shared/kbn-apm-ui-shared/tsconfig.json
Normal file
12
src/platform/packages/shared/kbn-apm-ui-shared/tsconfig.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "../../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": ["jest", "node", "react"]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["target/**/*"],
|
||||
"kbn_references": [
|
||||
"@kbn/i18n",
|
||||
]
|
||||
}
|
|
@ -15,7 +15,6 @@ import { createObservabilityTracesSpanDocumentProfileProvider } from './profile'
|
|||
import type { ContextWithProfileId } from '../../../../profile_service';
|
||||
import { OBSERVABILITY_ROOT_PROFILE_ID } from '../../consts';
|
||||
import type { ProfileProviderServices } from '../../../profile_provider_services';
|
||||
import { applicationMock } from '../__mocks__/application_mock';
|
||||
|
||||
describe('spanDocumentProfileProvider', () => {
|
||||
const getRootContext = ({
|
||||
|
@ -43,12 +42,12 @@ describe('spanDocumentProfileProvider', () => {
|
|||
isMatch: false,
|
||||
};
|
||||
|
||||
const mockServices: ProfileProviderServices = {
|
||||
...createContextAwarenessMocks().profileProviderServices,
|
||||
};
|
||||
|
||||
describe('when root profile is observability', () => {
|
||||
const profileId = OBSERVABILITY_ROOT_PROFILE_ID;
|
||||
const mockServices: ProfileProviderServices = {
|
||||
...createContextAwarenessMocks().profileProviderServices,
|
||||
...applicationMock({ apm: { show: true } }),
|
||||
};
|
||||
|
||||
const spanDocumentProfileProvider =
|
||||
createObservabilityTracesSpanDocumentProfileProvider(mockServices);
|
||||
|
@ -79,10 +78,6 @@ describe('spanDocumentProfileProvider', () => {
|
|||
|
||||
describe('when root profile is NOT observability', () => {
|
||||
const profileId = 'another-profile';
|
||||
const mockServices: ProfileProviderServices = {
|
||||
...createContextAwarenessMocks().profileProviderServices,
|
||||
...applicationMock({}),
|
||||
};
|
||||
|
||||
const spanDocumentProfileProvider =
|
||||
createObservabilityTracesSpanDocumentProfileProvider(mockServices);
|
||||
|
|
|
@ -11,9 +11,11 @@ import React from 'react';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { UnifiedDocViewerObservabilityTracesTransactionOverview } from '@kbn/unified-doc-viewer-plugin/public';
|
||||
import type { DocViewsRegistry } from '@kbn/unified-doc-viewer';
|
||||
import type { DocumentProfileProvider } from '../../../../..';
|
||||
import type { DocViewerExtensionParams, DocViewerExtension } from '../../../../../types';
|
||||
|
||||
export const getDocViewer =
|
||||
export const createGetDocViewer =
|
||||
(tracesIndexPattern: string): DocumentProfileProvider['profile']['getDocViewer'] =>
|
||||
(prev: (params: DocViewerExtensionParams) => DocViewerExtension) =>
|
||||
(params: DocViewerExtensionParams) => {
|
||||
const prevDocViewer = prev(params);
|
||||
|
@ -31,7 +33,12 @@ export const getDocViewer =
|
|||
),
|
||||
order: 0,
|
||||
component: (props) => {
|
||||
return <UnifiedDocViewerObservabilityTracesTransactionOverview {...props} />;
|
||||
return (
|
||||
<UnifiedDocViewerObservabilityTracesTransactionOverview
|
||||
{...props}
|
||||
tracesIndexPattern={tracesIndexPattern}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -7,4 +7,4 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export { getDocViewer } from './doc_viewer';
|
||||
export { createGetDocViewer } from './doc_viewer';
|
||||
|
|
|
@ -13,6 +13,8 @@ import { DataSourceCategory, DocumentType, SolutionType } from '../../../../prof
|
|||
import { createObservabilityTracesTransactionDocumentProfileProvider } from './profile';
|
||||
import type { ContextWithProfileId } from '../../../../profile_service';
|
||||
import { OBSERVABILITY_ROOT_PROFILE_ID } from '../../consts';
|
||||
import { createContextAwarenessMocks } from '../../../../__mocks__';
|
||||
import type { ProfileProviderServices } from '../../../profile_provider_services';
|
||||
|
||||
describe('transactionDocumentProfileProvider', () => {
|
||||
const getRootContext = ({
|
||||
|
@ -40,10 +42,14 @@ describe('transactionDocumentProfileProvider', () => {
|
|||
isMatch: false,
|
||||
};
|
||||
|
||||
const mockServices: ProfileProviderServices = {
|
||||
...createContextAwarenessMocks().profileProviderServices,
|
||||
};
|
||||
|
||||
describe('when root profile is observability', () => {
|
||||
const profileId = OBSERVABILITY_ROOT_PROFILE_ID;
|
||||
const transactionDocumentProfileProvider =
|
||||
createObservabilityTracesTransactionDocumentProfileProvider();
|
||||
createObservabilityTracesTransactionDocumentProfileProvider(mockServices);
|
||||
|
||||
it('matches records with the correct data stream type and the correct processor event', () => {
|
||||
expect(
|
||||
|
@ -72,7 +78,7 @@ describe('transactionDocumentProfileProvider', () => {
|
|||
describe('when root profile is NOT observability', () => {
|
||||
const profileId = 'another-profile';
|
||||
const transactionDocumentProfileProvider =
|
||||
createObservabilityTracesTransactionDocumentProfileProvider();
|
||||
createObservabilityTracesTransactionDocumentProfileProvider(mockServices);
|
||||
|
||||
it('does not match records with the correct data stream type and the correct processor event', () => {
|
||||
expect(
|
||||
|
|
|
@ -11,42 +11,44 @@ import type { DataTableRecord } from '@kbn/discover-utils';
|
|||
import { DATASTREAM_TYPE_FIELD, getFieldValue, PROCESSOR_EVENT_FIELD } from '@kbn/discover-utils';
|
||||
import type { DocumentProfileProvider } from '../../../../profiles';
|
||||
import { DocumentType } from '../../../../profiles';
|
||||
import { getDocViewer } from './accessors';
|
||||
import { createGetDocViewer } from './accessors';
|
||||
import { OBSERVABILITY_ROOT_PROFILE_ID } from '../../consts';
|
||||
import type { ProfileProviderServices } from '../../../profile_provider_services';
|
||||
|
||||
const OBSERVABILITY_TRACES_TRANSACTION_DOCUMENT_PROFILE_ID =
|
||||
'observability-traces-transaction-document-profile';
|
||||
|
||||
export const createObservabilityTracesTransactionDocumentProfileProvider =
|
||||
(): DocumentProfileProvider => ({
|
||||
isExperimental: true,
|
||||
profileId: OBSERVABILITY_TRACES_TRANSACTION_DOCUMENT_PROFILE_ID,
|
||||
profile: {
|
||||
getDocViewer,
|
||||
},
|
||||
resolve: ({ record, rootContext }) => {
|
||||
const isObservabilitySolutionView = rootContext.profileId === OBSERVABILITY_ROOT_PROFILE_ID;
|
||||
export const createObservabilityTracesTransactionDocumentProfileProvider = ({
|
||||
tracesContextService,
|
||||
}: ProfileProviderServices): DocumentProfileProvider => ({
|
||||
isExperimental: true,
|
||||
profileId: OBSERVABILITY_TRACES_TRANSACTION_DOCUMENT_PROFILE_ID,
|
||||
profile: {
|
||||
getDocViewer: createGetDocViewer(tracesContextService.getAllTracesIndexPattern() || ''),
|
||||
},
|
||||
resolve: ({ record, rootContext }) => {
|
||||
const isObservabilitySolutionView = rootContext.profileId === OBSERVABILITY_ROOT_PROFILE_ID;
|
||||
|
||||
if (!isObservabilitySolutionView) {
|
||||
return { isMatch: false };
|
||||
}
|
||||
if (!isObservabilitySolutionView) {
|
||||
return { isMatch: false };
|
||||
}
|
||||
|
||||
const isTransactionRecord = getIsTransactionRecord({
|
||||
record,
|
||||
});
|
||||
const isTransactionRecord = getIsTransactionRecord({
|
||||
record,
|
||||
});
|
||||
|
||||
if (!isTransactionRecord) {
|
||||
return { isMatch: false };
|
||||
}
|
||||
if (!isTransactionRecord) {
|
||||
return { isMatch: false };
|
||||
}
|
||||
|
||||
return {
|
||||
isMatch: true,
|
||||
context: {
|
||||
type: DocumentType.Transaction,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
return {
|
||||
isMatch: true,
|
||||
context: {
|
||||
type: DocumentType.Transaction,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const getIsTransactionRecord = ({ record }: { record: DataTableRecord }) => {
|
||||
return isTransactionDocument(record);
|
||||
|
|
|
@ -151,5 +151,5 @@ const createDocumentProfileProviders = (providerServices: ProfileProviderService
|
|||
createExampleDocumentProfileProvider(),
|
||||
createObservabilityLogDocumentProfileProvider(providerServices),
|
||||
createObservabilityTracesSpanDocumentProfileProvider(providerServices),
|
||||
createObservabilityTracesTransactionDocumentProfileProvider(),
|
||||
createObservabilityTracesTransactionDocumentProfileProvider(providerServices),
|
||||
];
|
||||
|
|
|
@ -12,7 +12,7 @@ import { renderHook, waitFor } from '@testing-library/react';
|
|||
import { lastValueFrom } from 'rxjs';
|
||||
import { getUnifiedDocViewerServices } from '../../../../../plugin';
|
||||
import { TransactionProvider, useTransactionContext } from './use_transaction';
|
||||
import { TRANSACTION_NAME_FIELD } from '@kbn/discover-utils';
|
||||
import { TRANSACTION_DURATION_FIELD, TRANSACTION_NAME_FIELD } from '@kbn/discover-utils';
|
||||
|
||||
jest.mock('../../../../../plugin', () => ({
|
||||
getUnifiedDocViewerServices: jest.fn(),
|
||||
|
@ -43,9 +43,11 @@ const mockAddDanger = jest.fn();
|
|||
},
|
||||
});
|
||||
|
||||
const lastValueFromMock = lastValueFrom as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(lastValueFrom as jest.Mock).mockReset();
|
||||
lastValueFromMock.mockReset();
|
||||
});
|
||||
|
||||
describe('useTransaction hook', () => {
|
||||
|
@ -56,7 +58,7 @@ describe('useTransaction hook', () => {
|
|||
);
|
||||
|
||||
it('should start with loading true and transaction as null', async () => {
|
||||
(lastValueFrom as jest.Mock).mockResolvedValue({});
|
||||
lastValueFromMock.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useTransactionContext(), { wrapper });
|
||||
|
||||
|
@ -66,10 +68,18 @@ describe('useTransaction hook', () => {
|
|||
|
||||
it('should update transaction when data is fetched successfully', async () => {
|
||||
const transactionName = 'Test Transaction';
|
||||
(lastValueFrom as jest.Mock).mockResolvedValue({
|
||||
const transactionDuration = 1;
|
||||
lastValueFromMock.mockResolvedValue({
|
||||
rawResponse: {
|
||||
hits: {
|
||||
hits: [{ fields: { [TRANSACTION_NAME_FIELD]: transactionName } }],
|
||||
hits: [
|
||||
{
|
||||
fields: {
|
||||
[TRANSACTION_NAME_FIELD]: transactionName,
|
||||
[TRANSACTION_DURATION_FIELD]: transactionDuration,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -80,19 +90,20 @@ describe('useTransaction hook', () => {
|
|||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.transaction?.name).toBe(transactionName);
|
||||
expect(result.current.transaction?.duration).toBe(transactionDuration);
|
||||
expect(lastValueFrom).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle errors and set transaction.name as empty string, and show a toast error', async () => {
|
||||
it('should handle errors and set transaction to null, and show a toast error', async () => {
|
||||
const errorMessage = 'Search error';
|
||||
(lastValueFrom as jest.Mock).mockRejectedValue(new Error(errorMessage));
|
||||
lastValueFromMock.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const { result } = renderHook(() => useTransactionContext(), { wrapper });
|
||||
|
||||
await waitFor(() => !result.current.loading);
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.transaction).toEqual({ name: '' });
|
||||
expect(result.current.transaction).toBeNull();
|
||||
expect(lastValueFrom).toHaveBeenCalledTimes(1);
|
||||
expect(mockAddDanger).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
@ -102,7 +113,7 @@ describe('useTransaction hook', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should set transaction.name as empty string and stop loading when transactionId is not provided', async () => {
|
||||
it('should set transaction to null and stop loading when transactionId is not provided', async () => {
|
||||
const wrapperWithoutTransactionId = ({ children }: { children: React.ReactNode }) => (
|
||||
<TransactionProvider transactionId={undefined} indexPattern="test-index">
|
||||
{children}
|
||||
|
@ -116,7 +127,7 @@ describe('useTransaction hook', () => {
|
|||
await waitFor(() => !result.current.loading);
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.transaction).toEqual({ name: '' });
|
||||
expect(result.current.transaction).toBeNull();
|
||||
expect(lastValueFrom).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -14,6 +14,7 @@ import { lastValueFrom } from 'rxjs';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
PROCESSOR_EVENT_FIELD,
|
||||
TRANSACTION_DURATION_FIELD,
|
||||
TRANSACTION_ID_FIELD,
|
||||
TRANSACTION_NAME_FIELD,
|
||||
} from '@kbn/discover-utils';
|
||||
|
@ -45,7 +46,7 @@ async function getTransactionData({
|
|||
size: 1,
|
||||
body: {
|
||||
timeout: '20s',
|
||||
fields: [TRANSACTION_NAME_FIELD],
|
||||
fields: [TRANSACTION_NAME_FIELD, TRANSACTION_DURATION_FIELD],
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
|
@ -70,18 +71,19 @@ async function getTransactionData({
|
|||
);
|
||||
}
|
||||
|
||||
export interface TransactionWithOnlyName {
|
||||
export interface Transaction {
|
||||
name: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
const useTransaction = ({ transactionId, indexPattern }: UseTransactionPrams) => {
|
||||
const { data, core } = getUnifiedDocViewerServices();
|
||||
const [transaction, setTransaction] = useState<TransactionWithOnlyName | null>(null);
|
||||
const [transaction, setTransaction] = useState<Transaction | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!transactionId) {
|
||||
setTransaction({ name: '' });
|
||||
setTransaction(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
@ -94,8 +96,14 @@ const useTransaction = ({ transactionId, indexPattern }: UseTransactionPrams) =>
|
|||
setLoading(true);
|
||||
const result = await getTransactionData({ transactionId, indexPattern, data, signal });
|
||||
|
||||
const transactionName = result.rawResponse.hits.hits[0]?.fields?.[TRANSACTION_NAME_FIELD];
|
||||
setTransaction(transactionName ? { name: transactionName } : null);
|
||||
const fields = result.rawResponse.hits.hits[0]?.fields;
|
||||
const transactionName = fields?.[TRANSACTION_NAME_FIELD];
|
||||
const transactionDuration = fields?.[TRANSACTION_DURATION_FIELD];
|
||||
|
||||
setTransaction({
|
||||
name: transactionName || null,
|
||||
duration: transactionDuration || null,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!signal.aborted) {
|
||||
const error = err as Error;
|
||||
|
@ -105,7 +113,7 @@ const useTransaction = ({ transactionId, indexPattern }: UseTransactionPrams) =>
|
|||
}),
|
||||
text: error.message,
|
||||
});
|
||||
setTransaction({ name: '' });
|
||||
setTransaction(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
|
@ -11,7 +11,6 @@ import {
|
|||
HTTP_RESPONSE_STATUS_CODE_FIELD,
|
||||
SERVICE_NAME_FIELD,
|
||||
SPAN_DESTINATION_SERVICE_RESOURCE_FIELD,
|
||||
SPAN_DURATION_FIELD,
|
||||
SPAN_NAME_FIELD,
|
||||
SPAN_SUBTYPE_FIELD,
|
||||
SPAN_TYPE_FIELD,
|
||||
|
@ -27,7 +26,6 @@ export const spanFields = [
|
|||
TRACE_ID_FIELD,
|
||||
SPAN_DESTINATION_SERVICE_RESOURCE_FIELD,
|
||||
TIMESTAMP_FIELD,
|
||||
SPAN_DURATION_FIELD,
|
||||
HTTP_RESPONSE_STATUS_CODE_FIELD,
|
||||
SPAN_TYPE_FIELD,
|
||||
SPAN_SUBTYPE_FIELD,
|
||||
|
|
|
@ -12,7 +12,6 @@ import {
|
|||
SPAN_DESTINATION_SERVICE_RESOURCE_FIELD,
|
||||
TraceDocumentOverview,
|
||||
SERVICE_ENVIRONMENT_FIELD,
|
||||
SPAN_DURATION_FIELD,
|
||||
SPAN_SUBTYPE_FIELD,
|
||||
SPAN_TYPE_FIELD,
|
||||
} from '@kbn/discover-utils';
|
||||
|
@ -24,7 +23,6 @@ import {
|
|||
FieldConfiguration,
|
||||
getCommonFieldConfiguration,
|
||||
} from '../../resources/get_field_configuration';
|
||||
import { asDuration } from '../../utils';
|
||||
|
||||
export const getSpanFieldConfiguration = (
|
||||
attributes: TraceDocumentOverview
|
||||
|
@ -38,13 +36,6 @@ export const getSpanFieldConfiguration = (
|
|||
content: (value) => <EuiText size="xs">{value}</EuiText>,
|
||||
value: attributes[SPAN_NAME_FIELD],
|
||||
},
|
||||
[SPAN_DURATION_FIELD]: {
|
||||
title: i18n.translate('unifiedDocViewer.observability.traces.details.spanDuration.title', {
|
||||
defaultMessage: 'Duration',
|
||||
}),
|
||||
content: (value) => <EuiText size="xs">{asDuration(value as number)}</EuiText>,
|
||||
value: attributes[SPAN_DURATION_FIELD] ?? 0,
|
||||
},
|
||||
[SPAN_DESTINATION_SERVICE_RESOURCE_FIELD]: {
|
||||
title: i18n.translate(
|
||||
'unifiedDocViewer.observability.traces.details.spanDestinationServiceResource.title',
|
||||
|
|
|
@ -11,12 +11,18 @@ import React from 'react';
|
|||
import { DocViewRenderProps } from '@kbn/unified-doc-viewer/types';
|
||||
import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { TRANSACTION_ID_FIELD, getTraceDocumentOverview } from '@kbn/discover-utils';
|
||||
import {
|
||||
SPAN_DURATION_FIELD,
|
||||
TRANSACTION_ID_FIELD,
|
||||
getTraceDocumentOverview,
|
||||
} from '@kbn/discover-utils';
|
||||
import { FieldActionsProvider } from '../../../../hooks/use_field_actions';
|
||||
import { TransactionProvider } from './hooks/use_transaction';
|
||||
import { spanFields } from './resources/fields';
|
||||
import { getSpanFieldConfiguration } from './resources/get_span_field_configuration';
|
||||
import { SpanSummary } from './sub_components/span_summary';
|
||||
import { SpanSummaryField } from './sub_components/span_summary_field';
|
||||
import { SpanDurationSummary } from './sub_components/span_duration_summary';
|
||||
|
||||
export type SpanOverviewProps = DocViewRenderProps & {
|
||||
transactionIndexPattern: string;
|
||||
};
|
||||
|
@ -30,10 +36,7 @@ export function SpanOverview({
|
|||
transactionIndexPattern,
|
||||
}: SpanOverviewProps) {
|
||||
const parsedDoc = getTraceDocumentOverview(hit);
|
||||
|
||||
const detailTitle = i18n.translate('unifiedDocViewer.observability.traces.spanOverview.title', {
|
||||
defaultMessage: 'Span detail',
|
||||
});
|
||||
const spanDuration = parsedDoc[SPAN_DURATION_FIELD];
|
||||
|
||||
return (
|
||||
<TransactionProvider
|
||||
|
@ -49,20 +52,31 @@ export function SpanOverview({
|
|||
<EuiPanel color="transparent" hasShadow={false} paddingSize="none">
|
||||
<EuiSpacer size="m" />
|
||||
<EuiTitle size="s">
|
||||
<h2>{detailTitle}</h2>
|
||||
<h2>
|
||||
{i18n.translate('unifiedDocViewer.observability.traces.spanOverview.title', {
|
||||
defaultMessage: 'Span detail',
|
||||
})}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="m" />
|
||||
{spanFields.map((fieldId) => {
|
||||
const fieldConfiguration = getSpanFieldConfiguration(parsedDoc)[fieldId];
|
||||
|
||||
return (
|
||||
<SpanSummary
|
||||
<SpanSummaryField
|
||||
key={fieldId}
|
||||
fieldId={fieldId}
|
||||
fieldConfiguration={fieldConfiguration}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{spanDuration && (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<SpanDurationSummary duration={spanDuration} />
|
||||
</>
|
||||
)}
|
||||
</EuiPanel>
|
||||
</FieldActionsProvider>
|
||||
</TransactionProvider>
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Duration } from '@kbn/apm-ui-shared';
|
||||
import { useTransactionContext } from '../../hooks/use_transaction';
|
||||
|
||||
export interface SpanDurationSummaryProps {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export function SpanDurationSummary({ duration }: SpanDurationSummaryProps) {
|
||||
const { transaction, loading } = useTransactionContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiTitle size="s">
|
||||
<h2>
|
||||
{i18n.translate(
|
||||
'unifiedDocViewer.observability.traces.docViewerSpanOverview.spanDurationSummary.title',
|
||||
{
|
||||
defaultMessage: 'Duration',
|
||||
}
|
||||
)}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiText color="subdued" size="xs">
|
||||
{i18n.translate(
|
||||
'unifiedDocViewer.observability.traces.docViewerSpanOverview.spanDurationSummary.description',
|
||||
{
|
||||
defaultMessage: 'Time taken to complete this span from start to finish.',
|
||||
}
|
||||
)}
|
||||
</EuiText>
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={1}>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="xxxs">
|
||||
<h3>
|
||||
{i18n.translate(
|
||||
'unifiedDocViewer.observability.traces.docViewerSpanOverview.spanDurationSummary.duration.title',
|
||||
{
|
||||
defaultMessage: 'Duration',
|
||||
}
|
||||
)}
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiText size="xs">
|
||||
<Duration
|
||||
duration={duration}
|
||||
parent={{ loading, duration: transaction?.duration, type: 'transaction' }}
|
||||
/>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -13,12 +13,12 @@ import React, { useState, useEffect } from 'react';
|
|||
import { FieldWithActions } from '../../components/field_with_actions/field_with_actions';
|
||||
import { useTransactionContext } from '../hooks/use_transaction';
|
||||
import { FieldConfiguration } from '../../resources/get_field_configuration';
|
||||
export interface SpanSummaryProps {
|
||||
export interface SpanSummaryFieldProps {
|
||||
fieldId: string;
|
||||
fieldConfiguration: FieldConfiguration;
|
||||
}
|
||||
|
||||
export function SpanSummary({ fieldConfiguration, fieldId }: SpanSummaryProps) {
|
||||
export function SpanSummaryField({ fieldConfiguration, fieldId }: SpanSummaryFieldProps) {
|
||||
const { transaction, loading } = useTransactionContext();
|
||||
const [fieldValue, setFieldValue] = useState(fieldConfiguration.value);
|
||||
const isTransactionNameField = fieldId === TRANSACTION_NAME_FIELD;
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { getUnifiedDocViewerServices } from '../../../../../plugin';
|
||||
import { RootTransactionProvider, useRootTransactionContext } from './use_root_transaction';
|
||||
import { TRANSACTION_DURATION_FIELD, TRANSACTION_NAME_FIELD } from '@kbn/discover-utils';
|
||||
|
||||
jest.mock('../../../../../plugin', () => ({
|
||||
getUnifiedDocViewerServices: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('rxjs', () => {
|
||||
const originalModule = jest.requireActual('rxjs');
|
||||
return {
|
||||
...originalModule,
|
||||
lastValueFrom: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockSearch = jest.fn();
|
||||
const mockAddDanger = jest.fn();
|
||||
(getUnifiedDocViewerServices as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
search: {
|
||||
search: mockSearch,
|
||||
},
|
||||
},
|
||||
core: {
|
||||
notifications: {
|
||||
toasts: {
|
||||
addDanger: mockAddDanger,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const lastValueFromMock = lastValueFrom as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
lastValueFromMock.mockReset();
|
||||
});
|
||||
|
||||
describe('useRootTransaction hook', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<RootTransactionProvider traceId="test-trace" indexPattern="test-index">
|
||||
{children}
|
||||
</RootTransactionProvider>
|
||||
);
|
||||
|
||||
it('should start with loading true and transaction as null', async () => {
|
||||
lastValueFromMock.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useRootTransactionContext(), { wrapper });
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(lastValueFrom).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should update transaction when data is fetched successfully', async () => {
|
||||
const transactionName = 'Test Transaction';
|
||||
const transactionDuration = 1;
|
||||
lastValueFromMock.mockResolvedValue({
|
||||
rawResponse: {
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
fields: {
|
||||
[TRANSACTION_NAME_FIELD]: transactionName,
|
||||
[TRANSACTION_DURATION_FIELD]: transactionDuration,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRootTransactionContext(), { wrapper });
|
||||
|
||||
await waitFor(() => !result.current.loading);
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.transaction?.duration).toBe(transactionDuration);
|
||||
expect(lastValueFrom).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle errors and set transaction to null, and show a toast error', async () => {
|
||||
const errorMessage = 'Search error';
|
||||
lastValueFromMock.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const { result } = renderHook(() => useRootTransactionContext(), { wrapper });
|
||||
|
||||
await waitFor(() => !result.current.loading);
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.transaction).toBeNull();
|
||||
expect(lastValueFrom).toHaveBeenCalledTimes(1);
|
||||
expect(mockAddDanger).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'An error occurred while fetching the transaction',
|
||||
text: errorMessage,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,129 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import createContainer from 'constate';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { PARENT_ID_FIELD, TRACE_ID_FIELD, TRANSACTION_DURATION_FIELD } from '@kbn/discover-utils';
|
||||
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { getUnifiedDocViewerServices } from '../../../../../plugin';
|
||||
|
||||
interface UseRootTransactionParams {
|
||||
traceId: string;
|
||||
indexPattern: string;
|
||||
}
|
||||
|
||||
interface GetRootTransactionParams {
|
||||
data: DataPublicPluginStart;
|
||||
signal: AbortSignal;
|
||||
traceId: string;
|
||||
indexPattern: string;
|
||||
}
|
||||
|
||||
async function getRootTransaction({
|
||||
data,
|
||||
signal,
|
||||
traceId,
|
||||
indexPattern,
|
||||
}: GetRootTransactionParams) {
|
||||
return lastValueFrom(
|
||||
data.search.search(
|
||||
{
|
||||
params: {
|
||||
index: indexPattern,
|
||||
size: 1,
|
||||
body: {
|
||||
timeout: '20s',
|
||||
fields: [TRANSACTION_DURATION_FIELD],
|
||||
query: {
|
||||
bool: {
|
||||
should: [
|
||||
{
|
||||
constant_score: {
|
||||
filter: {
|
||||
bool: {
|
||||
must_not: { exists: { field: PARENT_ID_FIELD } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
filter: [{ term: { [TRACE_ID_FIELD]: traceId } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ abortSignal: signal }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
const useRootTransaction = ({ traceId, indexPattern }: UseRootTransactionParams) => {
|
||||
const { core, data } = getUnifiedDocViewerServices();
|
||||
const [transaction, setTransaction] = useState<Transaction | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!traceId) {
|
||||
setTransaction(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await getRootTransaction({ data, signal, traceId, indexPattern });
|
||||
|
||||
const fields = result.rawResponse.hits.hits[0]?.fields;
|
||||
const transactionDuration = fields?.[TRANSACTION_DURATION_FIELD];
|
||||
|
||||
setTransaction({
|
||||
duration: transactionDuration || null,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!signal.aborted) {
|
||||
const error = err as Error;
|
||||
core.notifications.toasts.addDanger({
|
||||
title: i18n.translate(
|
||||
'unifiedDocViewer.docViewerSpanOverview.useRootTransaction.error',
|
||||
{
|
||||
defaultMessage: 'An error occurred while fetching the transaction',
|
||||
}
|
||||
),
|
||||
text: error.message,
|
||||
});
|
||||
setTransaction(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
return function onUnmount() {
|
||||
controller.abort();
|
||||
};
|
||||
}, [data, core.notifications.toasts, traceId, indexPattern]);
|
||||
|
||||
return { loading, transaction };
|
||||
};
|
||||
|
||||
export const [RootTransactionProvider, useRootTransactionContext] =
|
||||
createContainer(useRootTransaction);
|
|
@ -12,7 +12,6 @@ import {
|
|||
SERVICE_NAME_FIELD,
|
||||
TIMESTAMP_FIELD,
|
||||
TRACE_ID_FIELD,
|
||||
TRANSACTION_DURATION_FIELD,
|
||||
TRANSACTION_NAME_FIELD,
|
||||
USER_AGENT_NAME_FIELD,
|
||||
USER_AGENT_VERSION_FIELD,
|
||||
|
@ -23,7 +22,6 @@ export const transactionFields = [
|
|||
SERVICE_NAME_FIELD,
|
||||
TRACE_ID_FIELD,
|
||||
TIMESTAMP_FIELD,
|
||||
TRANSACTION_DURATION_FIELD,
|
||||
HTTP_RESPONSE_STATUS_CODE_FIELD,
|
||||
USER_AGENT_NAME_FIELD,
|
||||
USER_AGENT_VERSION_FIELD,
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
TRANSACTION_DURATION_FIELD,
|
||||
USER_AGENT_NAME_FIELD,
|
||||
TraceDocumentOverview,
|
||||
USER_AGENT_VERSION_FIELD,
|
||||
|
@ -16,7 +15,6 @@ import {
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import { EuiText } from '@elastic/eui';
|
||||
import { asDuration } from '../../utils';
|
||||
import {
|
||||
FieldConfiguration,
|
||||
getCommonFieldConfiguration,
|
||||
|
@ -27,16 +25,6 @@ export const getTransactionFieldConfiguration = (
|
|||
): Record<string, FieldConfiguration> => {
|
||||
return {
|
||||
...getCommonFieldConfiguration(attributes),
|
||||
[TRANSACTION_DURATION_FIELD]: {
|
||||
title: i18n.translate(
|
||||
'unifiedDocViewer.observability.traces.details.transactionDuration.title',
|
||||
{
|
||||
defaultMessage: 'Duration',
|
||||
}
|
||||
),
|
||||
content: (value) => <EuiText size="xs">{asDuration(value as number)}</EuiText>,
|
||||
value: attributes[TRANSACTION_DURATION_FIELD] ?? 0,
|
||||
},
|
||||
[USER_AGENT_NAME_FIELD]: {
|
||||
title: i18n.translate('unifiedDocViewer.observability.traces.details.userAgent.title', {
|
||||
defaultMessage: 'User agent',
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Duration } from '@kbn/apm-ui-shared';
|
||||
import { useRootTransactionContext } from '../../hooks/use_root_transaction';
|
||||
|
||||
export interface TransactionDurationSummaryProps {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export function TransactionDurationSummary({ duration }: TransactionDurationSummaryProps) {
|
||||
const { transaction, loading } = useRootTransactionContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiTitle size="s">
|
||||
<h2>
|
||||
{i18n.translate(
|
||||
'unifiedDocViewer.observability.traces.docViewerTransactionOverview.spanDurationSummary.title',
|
||||
{
|
||||
defaultMessage: 'Duration',
|
||||
}
|
||||
)}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiText color="subdued" size="xs">
|
||||
{i18n.translate(
|
||||
'unifiedDocViewer.observability.traces.docViewerTransactionOverview.spanDurationSummary.description',
|
||||
{
|
||||
defaultMessage: 'Time taken to complete this transaction from start to finish.',
|
||||
}
|
||||
)}
|
||||
</EuiText>
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={1}>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="xxxs">
|
||||
<h3>
|
||||
{i18n.translate(
|
||||
'unifiedDocViewer.observability.traces.docViewerTransactionOverview.spanDurationSummary.duration.title',
|
||||
{
|
||||
defaultMessage: 'Duration',
|
||||
}
|
||||
)}
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiText size="xs">
|
||||
<Duration
|
||||
duration={duration}
|
||||
parent={{ type: 'trace', duration: transaction?.duration, loading }}
|
||||
/>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -12,12 +12,15 @@ import React from 'react';
|
|||
import { FieldConfiguration } from '../../resources/get_field_configuration';
|
||||
import { FieldWithActions } from '../../components/field_with_actions/field_with_actions';
|
||||
|
||||
export interface TransactionSummaryProps {
|
||||
export interface TransactionSummaryFieldProps {
|
||||
fieldId: string;
|
||||
fieldConfiguration: FieldConfiguration;
|
||||
}
|
||||
|
||||
export function TransactionSummary({ fieldConfiguration, fieldId }: TransactionSummaryProps) {
|
||||
export function TransactionSummaryField({
|
||||
fieldConfiguration,
|
||||
fieldId,
|
||||
}: TransactionSummaryFieldProps) {
|
||||
if (!fieldConfiguration.value) {
|
||||
return null;
|
||||
}
|
|
@ -11,12 +11,20 @@ import React from 'react';
|
|||
import { DocViewRenderProps } from '@kbn/unified-doc-viewer/types';
|
||||
import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { getTraceDocumentOverview } from '@kbn/discover-utils';
|
||||
import {
|
||||
TRACE_ID_FIELD,
|
||||
TRANSACTION_DURATION_FIELD,
|
||||
getTraceDocumentOverview,
|
||||
} from '@kbn/discover-utils';
|
||||
import { FieldActionsProvider } from '../../../../hooks/use_field_actions';
|
||||
import { transactionFields } from './resources/fields';
|
||||
import { getTransactionFieldConfiguration } from './resources/get_transaction_field_configuration';
|
||||
import { TransactionSummary } from './sub_components/transaction_summary';
|
||||
export type TransactionOverviewProps = DocViewRenderProps;
|
||||
import { TransactionSummaryField } from './sub_components/transaction_summary_field';
|
||||
import { TransactionDurationSummary } from './sub_components/transaction_duration_summary';
|
||||
import { RootTransactionProvider } from './hooks/use_root_transaction';
|
||||
export type TransactionOverviewProps = DocViewRenderProps & {
|
||||
tracesIndexPattern: string;
|
||||
};
|
||||
|
||||
export function TransactionOverview({
|
||||
columns,
|
||||
|
@ -24,8 +32,10 @@ export function TransactionOverview({
|
|||
filter,
|
||||
onAddColumn,
|
||||
onRemoveColumn,
|
||||
tracesIndexPattern,
|
||||
}: TransactionOverviewProps) {
|
||||
const parsedDoc = getTraceDocumentOverview(hit);
|
||||
const transactionDuration = parsedDoc[TRANSACTION_DURATION_FIELD];
|
||||
|
||||
const detailTitle = i18n.translate(
|
||||
'unifiedDocViewer.observability.traces.transactionOverview.title',
|
||||
|
@ -35,30 +45,39 @@ export function TransactionOverview({
|
|||
);
|
||||
|
||||
return (
|
||||
<FieldActionsProvider
|
||||
columns={columns}
|
||||
filter={filter}
|
||||
onAddColumn={onAddColumn}
|
||||
onRemoveColumn={onRemoveColumn}
|
||||
>
|
||||
<EuiPanel color="transparent" hasShadow={false} paddingSize="none">
|
||||
<EuiSpacer size="m" />
|
||||
<EuiTitle size="s">
|
||||
<h2>{detailTitle}</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="m" />
|
||||
{transactionFields.map((fieldId) => {
|
||||
const fieldConfiguration = getTransactionFieldConfiguration(parsedDoc)[fieldId];
|
||||
<RootTransactionProvider traceId={parsedDoc[TRACE_ID_FIELD]} indexPattern={tracesIndexPattern}>
|
||||
<FieldActionsProvider
|
||||
columns={columns}
|
||||
filter={filter}
|
||||
onAddColumn={onAddColumn}
|
||||
onRemoveColumn={onRemoveColumn}
|
||||
>
|
||||
<EuiPanel color="transparent" hasShadow={false} paddingSize="none">
|
||||
<EuiSpacer size="m" />
|
||||
<EuiTitle size="s">
|
||||
<h2>{detailTitle}</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="m" />
|
||||
{transactionFields.map((fieldId) => {
|
||||
const fieldConfiguration = getTransactionFieldConfiguration(parsedDoc)[fieldId];
|
||||
|
||||
return (
|
||||
<TransactionSummary
|
||||
key={fieldId}
|
||||
fieldId={fieldId}
|
||||
fieldConfiguration={fieldConfiguration}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</EuiPanel>
|
||||
</FieldActionsProvider>
|
||||
return (
|
||||
<TransactionSummaryField
|
||||
key={fieldId}
|
||||
fieldId={fieldId}
|
||||
fieldConfiguration={fieldConfiguration}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{transactionDuration && (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<TransactionDurationSummary duration={transactionDuration} />
|
||||
</>
|
||||
)}
|
||||
</EuiPanel>
|
||||
</FieldActionsProvider>
|
||||
</RootTransactionProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -41,6 +41,7 @@
|
|||
"@kbn/event-stacktrace",
|
||||
"@kbn/elastic-agent-utils",
|
||||
"@kbn/data-view-utils",
|
||||
"@kbn/apm-ui-shared",
|
||||
"@kbn/react-hooks"
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
|
|
|
@ -98,6 +98,8 @@
|
|||
"@kbn/apm-synthtrace-client/*": ["src/platform/packages/shared/kbn-apm-synthtrace-client/*"],
|
||||
"@kbn/apm-types": ["x-pack/platform/packages/shared/kbn-apm-types"],
|
||||
"@kbn/apm-types/*": ["x-pack/platform/packages/shared/kbn-apm-types/*"],
|
||||
"@kbn/apm-ui-shared": ["src/platform/packages/shared/kbn-apm-ui-shared"],
|
||||
"@kbn/apm-ui-shared/*": ["src/platform/packages/shared/kbn-apm-ui-shared/*"],
|
||||
"@kbn/apm-utils": ["src/platform/packages/shared/kbn-apm-utils"],
|
||||
"@kbn/apm-utils/*": ["src/platform/packages/shared/kbn-apm-utils/*"],
|
||||
"@kbn/app-link-test-plugin": ["src/platform/test/plugin_functional/plugins/app_link_test"],
|
||||
|
@ -2172,9 +2174,7 @@
|
|||
"@kbn/zod-helpers/*": ["src/platform/packages/shared/kbn-zod-helpers/*"],
|
||||
// END AUTOMATED PACKAGE LISTING
|
||||
// Allows for importing from `kibana` package for the exported types.
|
||||
"@emotion/core": [
|
||||
"typings/@emotion"
|
||||
]
|
||||
"@emotion/core": ["typings/@emotion"]
|
||||
},
|
||||
// Support .tsx files and transform JSX into calls to React.createElement
|
||||
"jsx": "react",
|
||||
|
|
|
@ -3886,6 +3886,10 @@
|
|||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/apm-ui-shared@link:src/platform/packages/shared/kbn-apm-ui-shared":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/apm-utils@link:src/platform/packages/shared/kbn-apm-utils":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
|
Loading…
Add table
Reference in a new issue