[APM] Storybook support for the new service map API response (#213980)

closes [213126](https://github.com/elastic/kibana/issues/213126)

## Summary

Add support for the new API response to the Service Map storybook



![storybook](https://github.com/user-attachments/assets/3e5fbf96-ccee-43a7-b64f-b5a81fd52998)
This commit is contained in:
Carlos Crespo 2025-03-12 12:38:11 +01:00 committed by GitHub
parent 84ae9c98c2
commit 4502a930d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 291 additions and 121 deletions

View file

@ -0,0 +1,35 @@
/*
* 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 type { ConnectionNode, ExitSpanDestination, ServiceMapSpan } from './types';
import { getConnections, getExternalConnectionNode, getServiceConnectionNode } from './utils';
export const getPaths = ({ spans }: { spans: ServiceMapSpan[] }) => {
const connections: ConnectionNode[][] = [];
const exitSpanDestinations: ExitSpanDestination[] = [];
for (const currentNode of spans) {
const exitSpanNode = getExternalConnectionNode(currentNode);
const serviceNode = getServiceConnectionNode(currentNode);
if (currentNode.destinationService) {
// maps an exit span to its destination service
exitSpanDestinations.push({
from: exitSpanNode,
to: getServiceConnectionNode(currentNode.destinationService),
});
}
// builds a connection between a service and an exit span
connections.push([serviceNode, exitSpanNode]);
}
return {
connections: getConnections(connections),
exitSpanDestinations,
};
};

View file

@ -24,6 +24,7 @@ import type {
export * from './utils';
export { getServiceMapNodes } from './get_service_map_nodes';
export { getPaths } from './get_paths';
export type {
Connection,

View file

@ -7,17 +7,23 @@
import {
EuiButton,
EuiCallOut,
EuiFieldNumber,
EuiFilePicker,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiSpacer,
EuiToolTip,
} from '@elastic/eui';
import type { Meta, Story } from '@storybook/react';
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { CodeEditor } from '@kbn/code-editor';
import { useArgs } from '@storybook/addons';
import {
getPaths,
getServiceMapNodes,
type ServiceMapResponse,
} from '../../../../../common/service_map';
import { Cytoscape } from '../cytoscape';
import { Centerer } from './centerer';
import exampleResponseHipsterStore from './example_response_hipster_store.json';
@ -26,36 +32,16 @@ import exampleResponseTodo from './example_response_todo.json';
import { generateServiceMapElements } from './generate_service_map_elements';
import { MockApmPluginStorybook } from '../../../../context/apm_plugin/mock_apm_plugin_storybook';
const STORYBOOK_PATH = 'app/ServiceMap/Example data';
const SESSION_STORAGE_KEY = `${STORYBOOK_PATH}/pre-loaded map`;
function getSessionJson() {
return window.sessionStorage.getItem(SESSION_STORAGE_KEY);
}
function setSessionJson(json: string) {
window.sessionStorage.setItem(SESSION_STORAGE_KEY, json);
}
function getHeight() {
return window.innerHeight - 300;
return window.innerHeight - 200;
}
const stories: Meta<{}> = {
title: 'app/ServiceMap/Example data',
component: Cytoscape,
decorators: [
(StoryComponent, { globals }) => {
return (
<MockApmPluginStorybook>
<StoryComponent />
</MockApmPluginStorybook>
);
},
],
decorators: [(wrappedStory) => <MockApmPluginStorybook>{wrappedStory()}</MockApmPluginStorybook>],
};
export default stories;
export const GenerateMap: Story<{}> = () => {
const [size, setSize] = useState<number>(10);
const [json, setJson] = useState<string>('');
@ -114,87 +100,106 @@ export const GenerateMap: Story<{}> = () => {
);
};
export const MapFromJSON: Story<{}> = () => {
const [json, setJson] = useState<string>(
getSessionJson() || JSON.stringify(exampleResponseTodo, null, 2)
);
const [error, setError] = useState<string | undefined>();
interface MapFromJSONArgs {
json: unknown;
}
const assertJSON: (json?: any) => asserts json is ServiceMapResponse = (json) => {
if (!!json && !('elements' in json || 'spans' in json)) {
throw new Error('invalid json');
}
};
const MapFromJSONTemplate: Story<MapFromJSONArgs> = (args) => {
const [{ json }, updateArgs] = useArgs();
const [error, setError] = useState<string | undefined>();
const [elements, setElements] = useState<any[]>([]);
const [uniqueKeyCounter, setUniqueKeyCounter] = useState<number>(0);
const updateRenderedElements = () => {
const updateRenderedElements = useCallback(() => {
try {
setElements(JSON.parse(json).elements);
assertJSON(json);
if ('elements' in json) {
setElements(json.elements ?? []);
} else {
const paths = getPaths({ spans: json.spans ?? [] });
const nodes = getServiceMapNodes({
anomalies: json.anomalies ?? {
mlJobIds: [],
serviceAnomalies: [],
},
connections: paths.connections,
servicesData: json.servicesData ?? [],
exitSpanDestinations: paths.exitSpanDestinations,
});
setElements(nodes.elements);
}
setUniqueKeyCounter((key) => key + 1);
setError(undefined);
} catch (e) {
setError(e.message);
}
};
}, [json]);
useEffect(() => {
updateRenderedElements();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [updateRenderedElements]);
return (
<div>
<Cytoscape key={uniqueKeyCounter} elements={elements} height={getHeight()}>
<Centerer />
</Cytoscape>
<EuiForm isInvalid={error !== undefined} error={error}>
<EuiFlexGroup>
<EuiFlexItem>
<CodeEditor // TODO Unable to find context that provides theme. Need CODEOWNER Input
languageId="json"
value={json}
options={{ fontFamily: 'monospace' }}
onChange={(value) => {
setJson(value);
setSessionJson(value);
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="column">
<EuiFilePicker
display={'large'}
fullWidth={true}
style={{ height: '100%' }}
initialPromptText="Upload a JSON file"
onChange={(event) => {
const item = event?.item(0);
<EuiFlexGroup
direction="column"
justifyContent="spaceBetween"
style={{ minHeight: '100vh' }}
gutterSize="xs"
>
<EuiFlexItem grow={false}>
<EuiCallOut
size="s"
title="Upload a JSON file or paste a JSON object in the Storybook Controls panel."
iconType="pin"
/>
</EuiFlexItem>
<EuiFlexItem grow>
<Cytoscape key={uniqueKeyCounter} elements={elements} height={getHeight()}>
<Centerer />
</Cytoscape>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiForm isInvalid={error !== undefined} error={error}>
<EuiFilePicker
display="large"
fullWidth
initialPromptText="Upload a JSON file"
onChange={(event) => {
const item = event?.item(0);
if (item) {
const f = new FileReader();
f.onload = (onloadEvent) => {
const result = onloadEvent?.target?.result;
if (typeof result === 'string') {
setJson(result);
}
};
f.readAsText(item);
if (item) {
const f = new FileReader();
f.onload = (onloadEvent) => {
const result = onloadEvent?.target?.result;
if (typeof result === 'string') {
updateArgs({ json: JSON.parse(result) });
}
}}
/>
<EuiSpacer />
<EuiButton
data-test-subj="apmMapFromJSONRenderJsonButton"
onClick={() => {
updateRenderedElements();
}}
>
Render JSON
</EuiButton>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
</div>
};
f.readAsText(item);
}
}}
/>
</EuiForm>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const MapFromJSON = MapFromJSONTemplate.bind({});
MapFromJSON.argTypes = {
json: {
defaultValue: exampleResponseTodo,
control: 'object',
},
};
export const TodoApp: Story<{}> = () => {
return (
<div>
@ -224,3 +229,5 @@ export const HipsterStore: Story<{}> = () => {
</div>
);
};
export default stories;

View file

@ -0,0 +1,160 @@
{
"spans": [
{
"spanId": "f0dc50bb2415f74c",
"spanDestinationServiceResource": "sqlite/main",
"spanType": "db",
"spanSubtype": "sqlite",
"agentName": "dotnet",
"serviceName": "opbeans-dotnet",
"serviceEnvironment": "production"
},
{
"spanId": "1577a1c5d072d16a",
"spanDestinationServiceResource": "opbeans:3000",
"spanType": "external",
"spanSubtype": "http",
"agentName": "go",
"serviceName": "opbeans-go",
"serviceEnvironment": "opbeans",
"destinationService": {
"agentName": "nodejs",
"serviceEnvironment": "opbeans",
"serviceName": "opbeans-node"
}
},
{
"spanId": "6d4272837d04692f",
"spanDestinationServiceResource": "postgresql",
"spanType": "db",
"spanSubtype": "postgresql",
"agentName": "go",
"serviceName": "opbeans-go",
"serviceEnvironment": "opbeans"
},
{
"spanId": "20c6641d5ac8524a",
"spanDestinationServiceResource": "opbeans:3000",
"spanType": "external",
"spanSubtype": "http",
"agentName": "java",
"serviceName": "opbeans-java",
"serviceEnvironment": "opbeans",
"destinationService": {
"agentName": "nodejs",
"serviceEnvironment": "opbeans",
"serviceName": "opbeans-node"
}
},
{
"spanId": "33393ec7b7fe2a36",
"spanDestinationServiceResource": "postgresql/opbeans-java",
"spanType": "db",
"spanSubtype": "postgresql",
"agentName": "java",
"serviceName": "opbeans-java",
"serviceEnvironment": "opbeans"
},
{
"spanId": "14cd51ab5ee1d79d",
"spanDestinationServiceResource": "opbeans:3000",
"spanType": "external",
"spanSubtype": "http",
"agentName": "nodejs",
"serviceName": "opbeans-node",
"serviceEnvironment": "opbeans",
"destinationService": {
"agentName": "go",
"serviceEnvironment": "opbeans",
"serviceName": "opbeans-go"
}
},
{
"spanId": "3e9486b0e6d511d8",
"spanDestinationServiceResource": "postgresql/opbeans-node",
"spanType": "db",
"spanSubtype": "postgresql",
"agentName": "nodejs",
"serviceName": "opbeans-node",
"serviceEnvironment": "opbeans"
},
{
"spanId": "794ac250ad50c678",
"spanDestinationServiceResource": "redis",
"spanType": "db",
"spanSubtype": "redis",
"agentName": "nodejs",
"serviceName": "opbeans-node",
"serviceEnvironment": "opbeans"
},
{
"spanId": "6c39d0f33b597a69",
"spanDestinationServiceResource": "mysql/opbeans-php",
"spanType": "db",
"spanSubtype": "mysql",
"agentName": "php",
"serviceName": "opbeans-php",
"serviceEnvironment": "opbeans"
},
{
"spanId": "e97cd17b6bd9ffe1",
"spanDestinationServiceResource": "opbeans:3000",
"spanType": "external",
"spanSubtype": "http",
"agentName": "php",
"serviceName": "opbeans-php",
"serviceEnvironment": "opbeans",
"destinationService": {
"agentName": "go",
"serviceEnvironment": "opbeans",
"serviceName": "opbeans-go"
}
},
{
"spanId": "7017bb724934eb14",
"spanDestinationServiceResource": "opbeans:3000",
"spanType": "external",
"spanSubtype": "http",
"agentName": "python",
"serviceName": "opbeans-python",
"serviceEnvironment": "opbeans",
"destinationService": {
"agentName": "go",
"serviceEnvironment": "opbeans",
"serviceName": "opbeans-go"
}
},
{
"spanId": "0b737a7eb3a2d44c",
"spanDestinationServiceResource": "postgresql",
"spanType": "db",
"spanSubtype": "postgresql",
"agentName": "python",
"serviceName": "opbeans-python",
"serviceEnvironment": "opbeans"
},
{
"spanId": "042d9505225a6995",
"spanDestinationServiceResource": "opbeans:3000",
"spanType": "external",
"spanSubtype": "http",
"agentName": "ruby",
"serviceName": "opbeans-ruby",
"serviceEnvironment": "opbeans",
"destinationService": {
"agentName": "go",
"serviceEnvironment": "opbeans",
"serviceName": "opbeans-go"
}
},
{
"spanId": "b7708e76d6836ba6",
"spanDestinationServiceResource": "postgresql",
"spanType": "db",
"spanSubtype": "postgresql",
"agentName": "ruby",
"serviceName": "opbeans-ruby",
"serviceEnvironment": "opbeans"
}
]
}

View file

@ -7,8 +7,6 @@
import { useEffect, useState } from 'react';
import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public';
import type {
ServiceMapSpan,
ExitSpanDestination,
ServiceMapRawResponse,
ServiceMapTelemetry,
} from '../../../../common/service_map/types';
@ -16,13 +14,8 @@ import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_
import { useLicenseContext } from '../../../context/license/use_license_context';
import { isActivePlatinumLicense } from '../../../../common/license_check';
import type { Environment } from '../../../../common/environment_rt';
import {
getExternalConnectionNode,
getServiceConnectionNode,
getServiceMapNodes,
getConnections,
} from '../../../../common/service_map';
import type { ConnectionNode, GroupResourceNodesResponse } from '../../../../common/service_map';
import { getServiceMapNodes, getPaths } from '../../../../common/service_map';
import type { GroupResourceNodesResponse } from '../../../../common/service_map';
import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher';
type SeriviceMapState = GroupResourceNodesResponse & Pick<ServiceMapTelemetry, 'tracesCount'>;
@ -142,35 +135,9 @@ export const useServiceMap = ({
const processServiceMapData = (data: ServiceMapRawResponse): GroupResourceNodesResponse => {
const paths = getPaths({ spans: data.spans });
return getServiceMapNodes({
connections: getConnections(paths.connections),
connections: paths.connections,
exitSpanDestinations: paths.exitSpanDestinations,
servicesData: data.servicesData,
anomalies: data.anomalies,
});
};
const getPaths = ({ spans }: { spans: ServiceMapSpan[] }) => {
const connections: ConnectionNode[][] = [];
const exitSpanDestinations: ExitSpanDestination[] = [];
for (const currentNode of spans) {
const exitSpanNode = getExternalConnectionNode(currentNode);
const serviceNode = getServiceConnectionNode(currentNode);
if (currentNode.destinationService) {
// maps an exit span to its destination service
exitSpanDestinations.push({
from: exitSpanNode,
to: getServiceConnectionNode(currentNode.destinationService),
});
}
// builds a connection between a service and an exit span
connections.push([serviceNode, exitSpanNode]);
}
return {
connections,
exitSpanDestinations,
};
};