Merge pull request #12977 from isand3r/feat/dynatrace-plugin-synthetics

feat(plugin/dynatrace): synthetics monitor card to track recent failures
This commit is contained in:
Fredrik Adelöw
2022-08-18 11:25:56 +02:00
committed by GitHub
19 changed files with 489 additions and 46 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-dynatrace': minor
---
New features:
- Some visual improvements to the table that displays Problems
- Added support for viewing recent Synthetics results using
- Added some additional linking to the configured Dynatrace instance
+1 -1
View File
@@ -3,7 +3,7 @@ title: Dynatrace
author: TELUS
authorUrl: https://github.com/telus
category: Monitoring
description: View monitoring info from dynatrace for services in your software catalog.
description: View monitoring info from Dynatrace for services in your software catalog.
documentation: https://github.com/backstage/backstage/tree/master/plugins/dynatrace
iconUrl: img/dynatrace.svg
npmPackageName: '@backstage/plugin-dynatrace'
+19
View File
@@ -38,6 +38,8 @@ dynatrace:
#### Catalog Configuration
##### View Recent Application Problems
To show information from Dynatrace for a catalog entity, add the following annotation to `catalog-info.yaml`:
```yaml
@@ -51,6 +53,23 @@ metadata:
The `DYNATRACE_ENTITY_ID` can be found in Dynatrace by browsing to the entity (a service, synthetic, frontend, workload, etc.). It will be located in the browser address bar in the `id` parameter and has the format `ENTITY_TYPE-ENTITY_ID`, where `ENTITY_TYPE` will be one of `SERVICE`, `SYNTHETIC_TEST`, or other, and `ENTITY_ID` will be a string of characters containing uppercase letters and numbers.
##### Viewing Recent Synthetics Results
To show recent results from a Synthetic Monitor, add the following annotation to `catalog-info.yaml`:
```yaml
# catalog-info.yaml
# [...]
metadata:
annotations:
dynatrace.com/synthetics-ids: SYNTHETIC_ID, SYNTHETIC_ID_2, ...
# [...]
```
The annotation can also contain a comma or space separated list of Synthetic Ids to surface details for multiple monitors!
The `SYNTHETIC_ID` can be found in Dynatrace by browsing to the Synthetic monitor. It will be located in the browser address bar in the resource path - `https://example.dynatrace.com/ui/http-monitor/HTTP_CHECK-1234` for an Http check, or `https://example.dynatrace.com/ui/browser-monitor/SYNTHETIC_TEST-1234` for a browser clickpath.
## Disclaimer
This plugin is not officially supported by Dynatrace.
+30
View File
@@ -35,8 +35,32 @@ export type DynatraceProblem = {
affectedEntities: Array<DynatraceEntity>;
};
type SyntheticRequestResults = {
startTimestamp: number;
};
export interface DynatraceSyntheticResults {
monitorId: string;
locationsExecutionResults: [
{
locationId: number;
executionId: string;
requestResults: Array<SyntheticRequestResults>;
},
];
}
export interface DynatraceSyntheticLocationInfo {
entityId: string;
name: string;
city: string;
browserType: string;
}
export interface DynatraceProblems {
problems: Array<DynatraceProblem>;
totalCount: number;
pageSize: number;
}
export const dynatraceApiRef = createApiRef<DynatraceApi>({
@@ -47,4 +71,10 @@ export type DynatraceApi = {
getDynatraceProblems(
dynatraceEntityId: string,
): Promise<DynatraceProblems | undefined>;
getDynatraceSyntheticFailures(
syntheticsId: string,
): Promise<DynatraceSyntheticResults | undefined>;
getDynatraceSyntheticLocationInfo(
syntheticLocationId: string,
): Promise<DynatraceSyntheticLocationInfo | undefined>;
};
+33 -2
View File
@@ -13,7 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DynatraceProblems, DynatraceApi } from './DynatraceApi';
import {
DynatraceProblems,
DynatraceApi,
DynatraceSyntheticResults,
DynatraceSyntheticLocationInfo,
} from './DynatraceApi';
import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';
export class DynatraceClient implements DynatraceApi {
@@ -52,11 +57,37 @@ export class DynatraceClient implements DynatraceApi {
);
}
async getDynatraceSyntheticFailures(
syntheticsId: string,
): Promise<DynatraceSyntheticResults | undefined> {
if (!syntheticsId) {
throw new Error('Dynatrace syntheticId is required');
}
return this.callApi(
`synthetic/execution/${encodeURIComponent(syntheticsId)}/FAILED`,
{},
);
}
async getDynatraceSyntheticLocationInfo(
syntheticLocationId: string,
): Promise<DynatraceSyntheticLocationInfo | undefined> {
if (!syntheticLocationId) {
throw new Error('Dynatrace syntheticLocationId is required');
}
return this.callApi(
`synthetic/locations/${encodeURIComponent(syntheticLocationId)}`,
{},
);
}
async getDynatraceProblems(
dynatraceEntityId: string,
): Promise<DynatraceProblems | undefined> {
if (!dynatraceEntityId) {
throw new Error('Dynatrace entity ID is required');
throw new Error('Dynatrace entity id is required');
}
return this.callApi('problems', {
@@ -18,14 +18,16 @@ import { Grid } from '@material-ui/core';
import {
Page,
Content,
ContentHeader,
SupportButton,
MissingAnnotationEmptyState,
} from '@backstage/core-components';
import { useEntity } from '@backstage/plugin-catalog-react';
import { ProblemsList } from '../Problems/ProblemsList';
import { SyntheticsCard } from '../Synthetics/SyntheticsCard';
import { isDynatraceAvailable } from '../../plugin';
import { DYNATRACE_ID_ANNOTATION } from '../../constants';
import {
DYNATRACE_ID_ANNOTATION,
DYNATRACE_SYNTHETICS_ANNOTATION,
} from '../../constants';
export const DynatraceTab = () => {
const { entity } = useEntity();
@@ -37,18 +39,30 @@ export const DynatraceTab = () => {
const dynatraceEntityId: string =
entity?.metadata.annotations?.[DYNATRACE_ID_ANNOTATION]!;
const syntheticsIds: string =
entity?.metadata.annotations?.[DYNATRACE_SYNTHETICS_ANNOTATION]!;
return (
<Page themeId="tool">
<Content>
<ContentHeader title="Dynatrace">
<SupportButton>
Plugin to show information from Dynatrace
</SupportButton>
</ContentHeader>
<Grid container spacing={2}>
<Grid item xs={12} lg={12}>
<ProblemsList dynatraceEntityId={dynatraceEntityId} />
</Grid>
{dynatraceEntityId ? (
<Grid item xs={12} lg={12}>
<ProblemsList dynatraceEntityId={dynatraceEntityId} />
</Grid>
) : (
''
)}
{syntheticsIds
?.split(/[ ,]/)
.filter(Boolean)
.map(id => {
return (
<Grid item xs={12} lg={12}>
<SyntheticsCard syntheticsId={id} />
</Grid>
);
})}
</Grid>
</Content>
</Page>
@@ -36,12 +36,12 @@ describe('ProblemStatus', () => {
.mockResolvedValue({ problems });
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ProblemsList dynatraceEntityId="example-service-3" />
<ProblemsList dynatraceEntityId="__service_id__" />
</ApiProvider>,
);
expect(await rendered.findByText('example-service')).toBeInTheDocument();
});
it('renders "nothing to report :)" if no problems are found', async () => {
it('returns "No Problems to Report!" if no problems are found', async () => {
mockDynatraceApi.getDynatraceProblems = jest.fn().mockResolvedValue({});
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
@@ -49,7 +49,7 @@ describe('ProblemStatus', () => {
</ApiProvider>,
);
expect(
await rendered.findByText('Nothing to report :)'),
await rendered.findByText('No Problems to Report!'),
).toBeInTheDocument();
});
});
@@ -15,19 +15,40 @@
*/
import React from 'react';
import useAsync from 'react-use/lib/useAsync';
import { Progress } from '@backstage/core-components';
import Alert from '@material-ui/lab/Alert';
import { useApi } from '@backstage/core-plugin-api';
import {
Progress,
ResponseErrorPanel,
EmptyState,
} from '@backstage/core-components';
import { useApi, configApiRef } from '@backstage/core-plugin-api';
import { ProblemsTable } from '../ProblemsTable';
import { dynatraceApiRef } from '../../../api';
import { dynatraceApiRef, DynatraceProblem } from '../../../api';
import { InfoCard } from '@backstage/core-components';
type ProblemsListProps = {
dynatraceEntityId: string;
};
const cardContents = (
problems: DynatraceProblem[],
dynatraceBaseUrl: string,
) => {
return problems.length ? (
<ProblemsTable
problems={problems || []}
dynatraceBaseUrl={dynatraceBaseUrl}
/>
) : (
<EmptyState title="No Problems to Report!" missing="data" />
);
};
export const ProblemsList = (props: ProblemsListProps) => {
const { dynatraceEntityId } = props;
const configApi = useApi(configApiRef);
const dynatraceApi = useApi(dynatraceApiRef);
const dynatraceBaseUrl = configApi.getString('dynatrace.baseUrl');
const { value, loading, error } = useAsync(async () => {
return dynatraceApi.getDynatraceProblems(dynatraceEntityId);
}, [dynatraceApi, dynatraceEntityId]);
@@ -36,8 +57,18 @@ export const ProblemsList = (props: ProblemsListProps) => {
if (loading) {
return <Progress />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
return <ResponseErrorPanel error={error} />;
}
if (!problems) return <div>Nothing to report :)</div>;
return <ProblemsTable problems={problems} />;
return (
<InfoCard
title="Problems"
subheader={`Last 2 hours - ${dynatraceEntityId}`}
deepLink={{
title: 'View Entity in Dynatrace',
link: `${dynatraceBaseUrl}/#serviceOverview;id=${dynatraceEntityId}`,
}}
>
{cardContents(problems || [], dynatraceBaseUrl)}
</InfoCard>
);
};
@@ -28,17 +28,9 @@ describe('ProblemsTable', () => {
it('renders the table with some problem data', async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ProblemsTable problems={problems} />,
<ProblemsTable problems={problems} dynatraceBaseUrl="__dynatrace__" />,
</ApiProvider>,
);
expect(await rendered.findByText('example-service')).toBeInTheDocument();
});
it('renders an empty table when no data is provided', async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ProblemsTable problems={[]} />
</ApiProvider>,
);
expect(await rendered.findByText('Problems')).toBeInTheDocument();
expect(await rendered.findByTitle('Search')).toBeInTheDocument();
});
});
@@ -17,17 +17,19 @@ import React from 'react';
import { Table, TableColumn } from '@backstage/core-components';
import { DynatraceProblem } from '../../../api/DynatraceApi';
import { ProblemStatus } from '../ProblemStatus';
import { configApiRef } from '@backstage/core-plugin-api';
import { useApi } from '@backstage/core-plugin-api';
import { Link } from '@material-ui/core';
type ProblemsTableProps = {
problems: DynatraceProblem[];
dynatraceBaseUrl: string;
};
export const ProblemsTable = ({ problems }: ProblemsTableProps) => {
const configApi = useApi(configApiRef);
const dynatraceBaseUrl = configApi.getString('dynatrace.baseUrl');
const parseTimestamp = (timestamp: number | undefined) => {
return timestamp ? new Date(timestamp).toLocaleString() : 'N/A';
};
export const ProblemsTable = (props: ProblemsTableProps) => {
const { problems, dynatraceBaseUrl } = props;
const columns: TableColumn[] = [
{
title: 'Title',
@@ -62,20 +64,18 @@ export const ProblemsTable = ({ problems }: ProblemsTableProps) => {
{
title: 'Start Time',
field: 'startTime',
render: (row: Partial<DynatraceProblem>) =>
new Date(row.startTime || 0).toString(),
render: (row: Partial<DynatraceProblem>) => parseTimestamp(row.startTime),
},
{
title: 'End Time',
field: 'endTime',
render: (row: Partial<DynatraceProblem>) =>
row.endTime === -1 ? 'ongoing' : new Date(row.endTime || 0).toString(),
row.endTime === -1 ? 'ongoing' : parseTimestamp(row.endTime),
},
];
return (
<Table
title="Problems"
options={{ search: true, paging: true }}
columns={columns}
data={problems.map(p => {
@@ -0,0 +1,52 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { SyntheticsCard } from './SyntheticsCard';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import { dynatraceApiRef } from '../../../api';
import { ApiProvider, ConfigReader } from '@backstage/core-app-api';
import { configApiRef } from '@backstage/core-plugin-api';
const mockDynatraceApi = {
getDynatraceSyntheticFailures: jest.fn(),
};
const apis = TestApiRegistry.from(
[dynatraceApiRef, mockDynatraceApi],
[configApiRef, new ConfigReader({ dynatrace: { baseUrl: '__dynatrace__' } })],
);
describe('SyntheticsCard', () => {
it('renders the card with Synthetics data', async () => {
mockDynatraceApi.getDynatraceSyntheticFailures = jest
.fn()
.mockResolvedValue({
locationsExecutionResults: [
{
locationId: '__location__',
requestResults: [{ startTimestamp: 0 }],
},
],
});
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<SyntheticsCard syntheticsId="HTTP_CHECK-1234" />,
</ApiProvider>,
);
expect(
await rendered.findByText('View this Synthetic in Dynatrace'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,88 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import useAsync from 'react-use/lib/useAsync';
import { Progress, ResponseErrorPanel } from '@backstage/core-components';
import { InfoCard } from '@backstage/core-components';
import { useApi, configApiRef } from '@backstage/core-plugin-api';
import { dynatraceApiRef } from '../../../api';
import { SyntheticsLocation } from '../SyntheticsLocation';
type SyntheticsCardProps = {
syntheticsId: string;
};
const dynatraceMonitorPrefixes = (idPrefix: string): string => {
switch (idPrefix) {
case 'HTTP_CHECK':
return 'ui/http-monitor';
case 'BROWSER_MONITOR':
return 'ui/browser-monitor';
case 'SYNTHETIC_TEST':
return 'ui/browser-monitor';
default:
throw new Error('Invalid synthetic Id');
}
};
export const SyntheticsCard = (props: SyntheticsCardProps) => {
const { syntheticsId } = props;
const configApi = useApi(configApiRef);
const dynatraceApi = useApi(dynatraceApiRef);
const dynatraceBaseUrl = configApi.getString('dynatrace.baseUrl');
const { value, loading, error } = useAsync(async () => {
return dynatraceApi.getDynatraceSyntheticFailures(syntheticsId);
}, [dynatraceApi, syntheticsId]);
if (loading) {
return <Progress />;
} else if (error) {
return <ResponseErrorPanel error={error} />;
}
const deepLinkPrefix = dynatraceMonitorPrefixes(
`${syntheticsId.split('-')[0]}`,
);
const lastFailed = value?.locationsExecutionResults.map(l => {
return {
timestamp: l.requestResults[0].startTimestamp,
location: Number(l.locationId).toString(16),
};
});
return (
<InfoCard
title="Synthetics"
subheader={`Recent Activity for Monitor ${syntheticsId}`}
deepLink={{
title: 'View this Synthetic in Dynatrace',
link: `${dynatraceBaseUrl}/${deepLinkPrefix}/${syntheticsId}`,
}}
>
{lastFailed?.map(l => {
return (
<SyntheticsLocation
key={l.location}
lastFailedTimestamp={new Date(l.timestamp)}
locationId={l.location}
/>
);
})}
</InfoCard>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { SyntheticsCard } from './SyntheticsCard';
@@ -0,0 +1,65 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { SyntheticsLocation } from './SyntheticsLocation';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import { dynatraceApiRef } from '../../../api';
import { ApiProvider, ConfigReader } from '@backstage/core-app-api';
import { configApiRef } from '@backstage/core-plugin-api';
const mockDynatraceApi = {
getDynatraceSyntheticLocationInfo: jest.fn(),
};
const apis = TestApiRegistry.from(
[dynatraceApiRef, mockDynatraceApi],
[configApiRef, new ConfigReader({ dynatrace: { baseUrl: '__dynatrace__' } })],
);
describe('SyntheticsLocation', () => {
it('renders the SyntheticsLocation chip - recent failure', async () => {
mockDynatraceApi.getDynatraceSyntheticLocationInfo = jest
.fn()
.mockResolvedValue({ name: '__location__' });
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<SyntheticsLocation
lastFailedTimestamp={new Date()}
locationId="__location_id__"
key="__key__"
/>
,
</ApiProvider>,
);
expect(await rendered.findByText(/failed/)).toBeInTheDocument();
});
it('renders the SyntheticsLocation chip - no failures', async () => {
mockDynatraceApi.getDynatraceSyntheticLocationInfo = jest
.fn()
.mockResolvedValue({ name: '__location__' });
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<SyntheticsLocation
lastFailedTimestamp={new Date(0)}
locationId="__location_id__"
key="__key__"
/>
,
</ApiProvider>,
);
expect(await rendered.findByText(/__location__/)).toBeInTheDocument();
expect(await rendered.queryByText(/failed/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,75 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import useAsync from 'react-use/lib/useAsync';
import { Progress, ResponseErrorPanel } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { Chip } from '@material-ui/core';
import { dynatraceApiRef } from '../../../api';
type SyntheticsLocationProps = {
lastFailedTimestamp: Date;
locationId: string;
key: string;
};
const failedInLastXHours = (timestamp: Date, offset: number): boolean => {
if (offset < 0 || offset > 24)
throw new Error('offset must be between 0 and 24');
return timestamp > new Date(new Date().getTime() - 1000 * 60 * 60 * offset);
};
const chipColor = (timestamp: Date): string => {
if (failedInLastXHours(timestamp, 1)) {
return 'salmon';
}
if (failedInLastXHours(timestamp, 6)) {
return 'sandybrown';
}
if (failedInLastXHours(timestamp, 24)) {
return 'palegoldenrod';
}
return 'lightgreen';
};
export const SyntheticsLocation = (props: SyntheticsLocationProps) => {
const { lastFailedTimestamp, locationId } = props;
const dynatraceApi = useApi(dynatraceApiRef);
const { value, loading, error } = useAsync(async () => {
return dynatraceApi.getDynatraceSyntheticLocationInfo(
`SYNTHETIC_LOCATION-00000000000000${locationId}`,
);
});
if (loading) {
return <Progress />;
} else if (error) {
return <ResponseErrorPanel error={error} />;
}
return (
<Chip
label={`${value?.name}${
failedInLastXHours(new Date(lastFailedTimestamp), 24)
? `: failed @ ${lastFailedTimestamp.toLocaleTimeString()}`
: ''
}`}
size="medium"
style={{ backgroundColor: chipColor(lastFailedTimestamp) }}
/>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { SyntheticsLocation } from './SyntheticsLocation';
+1
View File
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export const DYNATRACE_ID_ANNOTATION = 'dynatrace.com/dynatrace-entity-id';
export const DYNATRACE_SYNTHETICS_ANNOTATION = 'dynatrace.com/synthetics-ids';
+1 -1
View File
@@ -1,5 +1,5 @@
{
"totalCount": 0,
"totalCount": 1,
"pageSize": 0,
"nextPageKey": "AQAAABQBAAAABQ==",
"problems": [
+6 -2
View File
@@ -23,7 +23,10 @@ import {
} from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { DYNATRACE_ID_ANNOTATION } from './constants';
import {
DYNATRACE_ID_ANNOTATION,
DYNATRACE_SYNTHETICS_ANNOTATION,
} from './constants';
import { rootRouteRef } from './routes';
@@ -55,7 +58,8 @@ export const dynatracePlugin = createPlugin({
* @param entity {Entity} - The entity to check for the dynatrace id annotation.
*/
export const isDynatraceAvailable = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[DYNATRACE_ID_ANNOTATION]);
Boolean(entity.metadata.annotations?.[DYNATRACE_ID_ANNOTATION]) ||
Boolean(entity.metadata.annotations?.[DYNATRACE_SYNTHETICS_ANNOTATION]);
/**
* Creates a routable extension for the dynatrace plugin tab.