Merge pull request #9816 from julioz/periskop-plugin

Add Periskop plugin
This commit is contained in:
Fredrik Adelöw
2022-03-07 15:33:57 +01:00
committed by GitHub
34 changed files with 1338 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-periskop': minor
'@backstage/plugin-periskop-backend': minor
---
Add periskop and periskop-backend plugin, for usage with exception aggregation tool https://periskop.io/
+2
View File
@@ -211,6 +211,8 @@ Patrik
Peloton
performant
Performant
periskop
Periskop
plantuml
Platformize
Podman
@@ -222,6 +222,20 @@ definition.
Specifying this annotation will enable GoCD related features in Backstage for
that entity.
### periskop.io/service-name
```yaml
# Example:
metadata:
annotations:
periskop.io/service-name: pump-station
```
The value of this annotation is the periskop project name for the given entity.
Specifying this annotation will enable [Periskop](https://periskop.io/) related features in Backstage for
that entity if the periskop plugin is installed.
### sentry.io/project-slug
```yaml
+9
View File
@@ -0,0 +1,9 @@
---
title: Periskop
author: Periskop
authorUrl: https://periskop.io/
category: Monitoring
description: Periskop is a pull-based, language agnostic exception aggregator for microservice environments.
documentation: https://github.com/backstage/backstage/tree/master/plugins/periskop
iconUrl: https://raw.githubusercontent.com/periskop-dev/periskop/master/docs/assets/periskop-logo.png
npmPackageName: '@backstage/plugin-periskop'
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+5
View File
@@ -0,0 +1,5 @@
# periskop-backend
Simple plugin that proxies requests to the Periskop server APIs.
To be used with the [Periskop plugin](../periskop/README.md).
+22
View File
@@ -0,0 +1,22 @@
## API Report File for "@backstage/plugin-periskop-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
// @public (undocumented)
export interface RouterOptions {
// (undocumented)
config: Config;
// (undocumented)
logger: Logger;
}
// (No @packageDocumentation comment for this package)
```
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@backstage/plugin-periskop-backend",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"homepage": "https://periskop.io",
"backstage": {
"role": "backend-plugin"
},
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"clean": "backstage-cli package clean"
},
"dependencies": {
"@backstage/backend-common": "^0.12.0",
"@backstage/config": "^0.1.15",
"@types/express": "*",
"cross-fetch": "^3.0.6",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"node-fetch": "^2.6.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.15.0",
"@types/supertest": "^2.0.8",
"msw": "^0.35.0",
"supertest": "^6.1.6"
},
"files": [
"dist"
]
}
+72
View File
@@ -0,0 +1,72 @@
/*
* 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 { Config } from '@backstage/config';
import { AggregatedError, NotFoundInInstance } from '../types';
import fetch from 'node-fetch';
export type Options = {
config: Config;
};
type PeriskopInstance = {
name: string;
url: string;
};
export class PeriskopApi {
private readonly instances: PeriskopInstance[];
constructor(options: Options) {
this.instances = options.config
.getConfigArray('periskop.instances')
.flatMap(locConf => {
const name = locConf.getString('name');
const url = locConf.getString('url');
return { name: name, url: url };
});
}
private getApiUrl(instanceName: string): string | undefined {
return this.instances.find(instance => instance.name === instanceName)?.url;
}
async getErrors(
instanceName: string,
serviceName: string,
): Promise<AggregatedError[] | NotFoundInInstance> {
const apiUrl = this.getApiUrl(instanceName);
if (!apiUrl) {
throw new Error(
`failed to fetch data, no periskop instance with name ${instanceName}`,
);
}
const response = await fetch(`${apiUrl}/services/${serviceName}/errors/`);
if (!response.ok) {
if (response.status === 404) {
return {
body: await response.text(),
};
}
throw new Error(
`failed to fetch data, status ${response.status}: ${response.statusText}`,
);
}
return response.json();
}
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 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 * from './service/router';
+33
View File
@@ -0,0 +1,33 @@
/*
* Copyright 2020 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 { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
@@ -0,0 +1,56 @@
/*
* Copyright 2020 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 { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
describe('createRouter', () => {
let app: express.Express;
beforeAll(async () => {
const router = await createRouter({
logger: getVoidLogger(),
config: new ConfigReader({
periskop: {
instances: [
{
name: 'db',
url: 'http://periskop-db',
},
],
},
}),
});
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('GET /health', () => {
it('returns ok', async () => {
const response = await request(app).get('/health');
expect(response.status).toEqual(200);
expect(response.body).toEqual({ status: 'ok' });
});
});
});
@@ -0,0 +1,57 @@
/*
* Copyright 2020 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 { errorHandler } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { PeriskopApi } from '../api/index';
import { Config } from '@backstage/config';
/**
* @public
*/
export interface RouterOptions {
logger: Logger;
config: Config;
}
/**
* @public
*/
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger, config } = options;
const periskopApi = new PeriskopApi({ config });
const router = Router();
router.use(express.json());
router.get('/health', (_, response) => {
logger.info('PONG!');
response.send({ status: 'ok' });
});
router.get('/:locationName/:serviceName', async (request, response) => {
const { locationName, serviceName } = request.params;
logger.info(`Periskop got queried for ${serviceName} in ${locationName}`);
const errors = await periskopApi.getErrors(locationName, serviceName);
response.status(200).json(errors);
});
router.use(errorHandler());
return router;
}
@@ -0,0 +1,56 @@
/*
* 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 {
createServiceBuilder,
loadBackendConfig,
} from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'periskop-backend' });
const config = await loadBackendConfig({ logger, argv: process.argv });
logger.debug('Starting application server...');
const router = await createRouter({
logger,
config,
});
let service = createServiceBuilder(module)
.setPort(options.port)
.addRouter('/periskop', router);
if (options.enableCors) {
service = service.enableCors({ origin: 'http://localhost:3000' });
}
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();
@@ -0,0 +1,17 @@
/*
* 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 {};
+48
View File
@@ -0,0 +1,48 @@
/*
* 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 interface AggregatedError {
aggregation_key: string;
total_count: number;
severity: string;
latest_errors: ErrorInstance[];
}
export interface ErrorInstance {
error: Error;
uuid: string;
timestamp: number;
severity: string;
http_context: HttpContext;
}
export interface Error {
class: string;
message: string;
stacktrace?: string[];
cause?: Error | null;
}
export interface HttpContext {
request_method: string;
request_url: string;
request_headers: RequestHeaders;
request_body: string;
}
export interface RequestHeaders {
[k: string]: string;
}
export interface NotFoundInInstance {
body: string;
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+67
View File
@@ -0,0 +1,67 @@
# periskop
[Periskop](https://periskop.io/) is a pull-based, language agnostic exception aggregator for microservice environments.
![periskop-logo](https://i.imgur.com/z8BLePO.png)
## Periskop aggregated errors
The Periskop Backstage Plugin exposes a component named `EntityPeriskopErrorsCard`.
Each of the entries in the table will direct you to the error details in your deployed Periskop instance location.
![periskop-errors-card](./docs/periskop-plugin-screenshot.png)
## Setup
1. Configure the [periskop backend plugin](https://github.com/backstage/backstage/tree/master/plugins/periskop-backend/)
2. Install the plugin by running:
```bash
# From your Backstage root directory
cd packages/app
yarn add @backstage/plugin-periskop
```
3. Add to the app `EntityPage` component:
```tsx
// In packages/app/src/components/catalog/EntityPage.tsx
import { EntityPeriskopErrorsCard } from '@backstage/plugin-periskop';
const componentPage = (
<EntityLayout>
{/* other tabs... */}
<EntityLayout.Route path="/periskop" title="Periskop">
<Grid container spacing={3} alignItems="stretch">
<Grid item xs={12} sm={12} md={12}>
<EntityPeriskopErrorsCard />
</Grid>
</Grid>
</EntityLayout.Route>
```
4. [Setup the `app-config.yaml`](#instances) `periskop` block
5. Annotate entities with the periskop service name
```yaml
annotations:
periskop.io/service-name: '<THE NAME OF THE PERISKOP APP>'
```
6. Run app with `yarn start` and navigate to `/periskop` or a catalog entity 'Periskop' tab
### Instances
The periskop plugin can be configured to fetch aggregated errors from multiple deployment instances.
This is especially useful if you have a multi-zone deployment, or a federated setup and would like to drill deeper into a single instance of the federation. Each of the configured instances will be included in the plugin's UI via a dropdown on the errors table.
The plugin requires to configure _at least one_ Periskop API location in the [app-config.yaml](https://github.com/backstage/backstage/blob/master/app-config.yaml):
```yaml
periskop:
instances:
- name: <name of the instance>
url: <HTTP/S url for the Periskop instance's API>
```
+131
View File
@@ -0,0 +1,131 @@
## API Report File for "@backstage/plugin-periskop"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { ConfigApi } from '@backstage/core-plugin-api';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
// @public (undocumented)
export interface AggregatedError {
// (undocumented)
aggregation_key: string;
// (undocumented)
latest_errors: ErrorInstance[];
// (undocumented)
severity: string;
// (undocumented)
total_count: number;
}
// @public (undocumented)
export const EntityPeriskopErrorsCard: () => JSX.Element;
// @public (undocumented)
interface Error_2 {
// (undocumented)
cause?: Error_2 | null;
// (undocumented)
class: string;
// (undocumented)
message: string;
// (undocumented)
stacktrace?: string[];
}
export { Error_2 as Error };
// @public (undocumented)
export interface ErrorInstance {
// (undocumented)
error: Error_2;
// (undocumented)
http_context: HttpContext;
// (undocumented)
severity: string;
// (undocumented)
timestamp: number;
// (undocumented)
uuid: string;
}
// @public (undocumented)
export interface HttpContext {
// (undocumented)
request_body: string;
// (undocumented)
request_headers: RequestHeaders;
// (undocumented)
request_method: string;
// (undocumented)
request_url: string;
}
// @public
export const isPeriskopAvailable: (entity: Entity) => boolean;
// @public (undocumented)
export interface NotFoundInInstance {
// (undocumented)
body: string;
}
// @public
export const PERISKOP_NAME_ANNOTATION = 'periskop.io/service-name';
// @public
export interface PeriskopApi {
getErrorInstanceUrl(
instanceName: string,
serviceName: string,
error: AggregatedError,
): string;
getErrors(
instanceName: string,
serviceName: string,
): Promise<AggregatedError[] | NotFoundInInstance>;
getInstanceNames(): string[];
}
// @public (undocumented)
export type PeriskopApiOptions = {
discoveryApi: DiscoveryApi;
configApi: ConfigApi;
};
// @public (undocumented)
export const periskopApiRef: ApiRef<PeriskopApi>;
// @public
export class PeriskopClient implements PeriskopApi {
constructor(options: PeriskopApiOptions);
// (undocumented)
getErrorInstanceUrl(
instanceName: string,
serviceName: string,
error: AggregatedError,
): string;
// (undocumented)
getErrors(
instanceName: string,
serviceName: string,
): Promise<AggregatedError[] | NotFoundInInstance>;
// (undocumented)
getInstanceNames(): string[];
}
// @public (undocumented)
export const periskopPlugin: BackstagePlugin<{}, {}>;
// @public (undocumented)
export interface RequestHeaders {
// (undocumented)
[k: string]: string;
}
// (No @packageDocumentation comment for this package)
```
+39
View File
@@ -0,0 +1,39 @@
/*
* 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 interface Config {
/**
* Configuration options for the periskop plugin
*/
periskop?: {
/**
* Integration configuration for the periskop servers
* @visibility frontend
*/
instances: Array<{
/**
* The name of the given Periskop instance
* @visibility frontend
*/
name: string;
/**
* The hostname of the given Periskop instance
* @visibility frontend
*/
url: string;
}>;
};
}
+20
View File
@@ -0,0 +1,20 @@
/*
* 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 { createDevApp } from '@backstage/dev-utils';
import { periskopPlugin } from '../src/plugin';
createDevApp().registerPlugin(periskopPlugin).render();
Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

+63
View File
@@ -0,0 +1,63 @@
{
"name": "@backstage/plugin-periskop",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"homepage": "https://periskop.io",
"backstage": {
"role": "frontend-plugin"
},
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli package build",
"start": "backstage-cli package start",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"clean": "backstage-cli package clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.12.0",
"@backstage/core-components": "^0.9.0",
"@backstage/core-plugin-api": "^0.8.0",
"@backstage/errors": "^0.2.2",
"@backstage/plugin-catalog-react": "^0.8.0",
"@backstage/theme": "^0.2.15",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"luxon": "^2.0.2",
"react-use": "^17.2.4"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0",
"react-dom": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.15.0",
"@backstage/core-app-api": "^0.6.0",
"@backstage/dev-utils": "^0.2.24",
"@backstage/test-utils": "^0.3.0",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
"@types/jest": "^26.0.7",
"@types/luxon": "^2.0.4",
"@types/node": "^14.14.32",
"cross-fetch": "^3.1.5",
"msw": "^0.35.0"
},
"files": [
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts"
}
+119
View File
@@ -0,0 +1,119 @@
/*
* 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 { ConfigApi, DiscoveryApi } from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import { AggregatedError, NotFoundInInstance } from '../types';
/** @public */
export type PeriskopApiOptions = {
discoveryApi: DiscoveryApi;
configApi: ConfigApi;
};
type PeriskopInstance = {
name: string;
url: string;
};
/**
* API abstraction to interact with Periskop's backends.
*
* @public
*/
export interface PeriskopApi {
/**
* Returns the list of registered Periskop instance names.
*/
getInstanceNames(): string[];
/**
* For the given instance and service, returns the URL pointing to the specific error instance occurrence.
* Note: This method might point to an external route.
*/
getErrorInstanceUrl(
instanceName: string,
serviceName: string,
error: AggregatedError,
): string;
/**
* Fetches all errors for the given service from the specified Periskop instance, given its name.
*/
getErrors(
instanceName: string,
serviceName: string,
): Promise<AggregatedError[] | NotFoundInInstance>;
}
/**
* API implementation to interact with Periskop's backends.
*
* @public
*/
export class PeriskopClient implements PeriskopApi {
private readonly discoveryApi: DiscoveryApi;
private readonly instances: PeriskopInstance[];
constructor(options: PeriskopApiOptions) {
this.discoveryApi = options.discoveryApi;
this.instances = options.configApi
.getConfigArray('periskop.instances')
.flatMap(locConf => {
const name = locConf.getString('name');
const url = locConf.getString('url');
return { name: name, url: url };
});
}
private getApiUrl(instanceName: string): string | undefined {
return this.instances.find(instance => instance.name === instanceName)?.url;
}
getInstanceNames(): string[] {
return this.instances.map(e => e.name);
}
getErrorInstanceUrl(
instanceName: string,
serviceName: string,
error: AggregatedError,
): string {
return `${this.getApiUrl(
instanceName,
)}/#/${serviceName}/errors/${encodeURIComponent(error.aggregation_key)}`;
}
async getErrors(
instanceName: string,
serviceName: string,
): Promise<AggregatedError[] | NotFoundInInstance> {
const apiUrl = `${await this.discoveryApi.getBaseUrl(
'periskop',
)}/${instanceName}/${serviceName}`;
const response = await fetch(apiUrl);
if (!response.ok) {
if (response.status === 404) {
return {
body: await response.text(),
};
}
throw await ResponseError.fromResponse(response);
}
return response.json();
}
}
@@ -0,0 +1,187 @@
/*
* 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 { DateTime } from 'luxon';
import { Entity } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import {
Content,
ContentHeader,
Table,
TableColumn,
Progress,
ResponseErrorPanel,
StatusWarning,
StatusError,
StatusPending,
Select,
EmptyState,
Link,
} from '@backstage/core-components';
import useAsync from 'react-use/lib/useAsync';
import { periskopApiRef } from '../..';
import { AggregatedError, NotFoundInInstance } from '../../types';
/**
* Constant storing Periskop project name.
*
* @public
*/
export const PERISKOP_NAME_ANNOTATION = 'periskop.io/service-name';
/**
* Returns true if Periskop annotation is present in the given entity.
*
* @public
*/
export const isPeriskopAvailable = (entity: Entity): boolean =>
Boolean(entity.metadata.annotations?.[PERISKOP_NAME_ANNOTATION]);
const renderKey = (
error: AggregatedError,
linkTarget: string,
): React.ReactNode => {
return <Link to={linkTarget}>{error.aggregation_key}</Link>;
};
const renderSeverity = (severity: string): React.ReactNode => {
if (severity.toLocaleLowerCase('en-US') === 'warning') {
return <StatusWarning>{severity}</StatusWarning>;
} else if (severity.toLocaleLowerCase('en-US') === 'error') {
return <StatusError>{severity}</StatusError>;
}
return <StatusPending>{severity}</StatusPending>;
};
const renderLastOccurrence = (error: AggregatedError): React.ReactNode => {
return DateTime.fromMillis(
error.latest_errors[0].timestamp * 1000,
).toRelative();
};
function isNotFoundInInstance(
apiResult: AggregatedError[] | NotFoundInInstance | undefined,
): apiResult is NotFoundInInstance {
return (apiResult as NotFoundInInstance)?.body !== undefined;
}
export const EntityPeriskopErrorsCard = () => {
const { entity } = useEntity();
const entityPeriskopName: string =
entity.metadata.annotations?.[PERISKOP_NAME_ANNOTATION] ??
entity.metadata.name;
const periskopApi = useApi(periskopApiRef);
const instanceNames = periskopApi.getInstanceNames();
const [instanceOption, setInstanceOption] = React.useState<string>(
instanceNames[0],
);
const {
value: aggregatedErrors,
loading,
error,
} = useAsync(async (): Promise<AggregatedError[] | NotFoundInInstance> => {
return periskopApi.getErrors(instanceOption, entityPeriskopName);
}, [instanceOption]);
if (loading) {
return <Progress />;
} else if (error) {
return <ResponseErrorPanel error={error} />;
}
const columns: TableColumn<AggregatedError>[] = [
{
title: 'Key',
field: 'aggregation_key',
highlight: true,
sorting: false,
render: aggregatedError => {
const errorUrl = periskopApi.getErrorInstanceUrl(
instanceOption,
entityPeriskopName,
aggregatedError,
);
return renderKey(aggregatedError, errorUrl);
},
},
{ title: 'Occurrences', field: 'total_count', sorting: true },
{
title: 'Last Occurrence',
render: aggregatedError => renderLastOccurrence(aggregatedError),
defaultSort: 'asc',
customSort: (a, b) =>
b.latest_errors[0].timestamp - a.latest_errors[0].timestamp,
type: 'datetime',
},
{
title: 'Severity',
field: 'severity',
render: aggregatedError => renderSeverity(aggregatedError.severity),
sorting: false,
},
];
const sortingSelect = (
<Select
selected={instanceOption}
label="Instance"
items={instanceNames.map(e => ({
label: e,
value: e,
}))}
onChange={el => setInstanceOption(el.toString())}
/>
);
const contentHeader = (
<ContentHeader title="Periskop" description={entityPeriskopName}>
{sortingSelect}
</ContentHeader>
);
if (isNotFoundInInstance(aggregatedErrors)) {
return (
<Content noPadding>
{contentHeader}
<EmptyState
title="Periskop returned an error"
description={aggregatedErrors.body}
missing="data"
/>
</Content>
);
}
return (
<Content noPadding>
{contentHeader}
<Table
options={{
search: false,
paging: false,
filtering: true,
padding: 'dense',
toolbar: false,
}}
columns={columns}
data={aggregatedErrors || []}
/>
</Content>
);
};
@@ -0,0 +1,21 @@
/*
* 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 {
EntityPeriskopErrorsCard,
isPeriskopAvailable,
PERISKOP_NAME_ANNOTATION,
} from './EntityPeriskopErrorsCard';
+33
View File
@@ -0,0 +1,33 @@
/*
* 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 { periskopPlugin } from './plugin';
import { createComponentExtension } from '@backstage/core-plugin-api';
/**
* @public
*/
export const EntityPeriskopErrorsCard = periskopPlugin.provide(
createComponentExtension({
name: 'EntityPeriskopErrorsCard',
component: {
lazy: () =>
import('./components/EntityPeriskopErrorsCard').then(
m => m.EntityPeriskopErrorsCard,
),
},
}),
);
+24
View File
@@ -0,0 +1,24 @@
/*
* 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 { periskopPlugin, periskopApiRef } from './plugin';
export { EntityPeriskopErrorsCard } from './extensions';
export {
isPeriskopAvailable,
PERISKOP_NAME_ANNOTATION,
} from './components/EntityPeriskopErrorsCard';
export * from './api/index';
export * from './types';
+23
View File
@@ -0,0 +1,23 @@
/*
* 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 { periskopPlugin } from './plugin';
describe('periskop', () => {
it('should export plugin', () => {
expect(periskopPlugin).toBeDefined();
});
});
+46
View File
@@ -0,0 +1,46 @@
/*
* 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 {
discoveryApiRef,
configApiRef,
createApiFactory,
createPlugin,
createApiRef,
} from '@backstage/core-plugin-api';
import { PeriskopApi, PeriskopClient } from './api';
/**
* @public
*/
export const periskopApiRef = createApiRef<PeriskopApi>({
id: 'plugin.periskop.service',
});
/**
* @public
*/
export const periskopPlugin = createPlugin({
id: 'periskop',
apis: [
createApiFactory({
api: periskopApiRef,
deps: { configApi: configApiRef, discoveryApi: discoveryApiRef },
factory: ({ configApi, discoveryApi }) =>
new PeriskopClient({ configApi, discoveryApi }),
}),
],
});
+21
View File
@@ -0,0 +1,21 @@
/*
* 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 { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
id: 'periskop',
});
+18
View File
@@ -0,0 +1,18 @@
/*
* 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 '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
+58
View File
@@ -0,0 +1,58 @@
/*
* 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.
*/
/** @public */
export interface AggregatedError {
aggregation_key: string;
total_count: number;
severity: string;
latest_errors: ErrorInstance[];
}
/** @public */
export interface ErrorInstance {
error: Error;
uuid: string;
timestamp: number;
severity: string;
http_context: HttpContext;
}
/** @public */
export interface Error {
class: string;
message: string;
stacktrace?: string[];
cause?: Error | null;
}
/** @public */
export interface HttpContext {
request_method: string;
request_url: string;
request_headers: RequestHeaders;
request_body: string;
}
/** @public */
export interface RequestHeaders {
[k: string]: string;
}
/** @public */
export interface NotFoundInInstance {
body: string;
}
+2
View File
@@ -230,6 +230,8 @@ const NO_WARNING_PACKAGES = [
'plugins/catalog-common',
'plugins/catalog-graph',
'plugins/catalog-react',
'plugins/periskop',
'plugins/periskop-backend',
'plugins/permission-backend',
'plugins/permission-common',
'plugins/permission-node',