diff --git a/.changeset/strong-pandas-roll.md b/.changeset/strong-pandas-roll.md new file mode 100644 index 0000000000..e58e1c44c6 --- /dev/null +++ b/.changeset/strong-pandas-roll.md @@ -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/ diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index aa94c3d953..2614dd6b50 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -211,6 +211,8 @@ Patrik Peloton performant Performant +periskop +Periskop plantuml Platformize Podman diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 26a8710820..4fa71329dd 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -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 diff --git a/microsite/data/plugins/periskop.yaml b/microsite/data/plugins/periskop.yaml new file mode 100644 index 0000000000..cce8d0dde5 --- /dev/null +++ b/microsite/data/plugins/periskop.yaml @@ -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' diff --git a/plugins/periskop-backend/.eslintrc.js b/plugins/periskop-backend/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/periskop-backend/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/periskop-backend/README.md b/plugins/periskop-backend/README.md new file mode 100644 index 0000000000..02552fe5b4 --- /dev/null +++ b/plugins/periskop-backend/README.md @@ -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). diff --git a/plugins/periskop-backend/api-report.md b/plugins/periskop-backend/api-report.md new file mode 100644 index 0000000000..caaed109f9 --- /dev/null +++ b/plugins/periskop-backend/api-report.md @@ -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; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + config: Config; + // (undocumented) + logger: Logger; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/periskop-backend/package.json b/plugins/periskop-backend/package.json new file mode 100644 index 0000000000..0efb9d7168 --- /dev/null +++ b/plugins/periskop-backend/package.json @@ -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" + ] +} diff --git a/plugins/periskop-backend/src/api/index.ts b/plugins/periskop-backend/src/api/index.ts new file mode 100644 index 0000000000..6245dcacfd --- /dev/null +++ b/plugins/periskop-backend/src/api/index.ts @@ -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 { + 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(); + } +} diff --git a/plugins/periskop-backend/src/index.ts b/plugins/periskop-backend/src/index.ts new file mode 100644 index 0000000000..ca73cb27ba --- /dev/null +++ b/plugins/periskop-backend/src/index.ts @@ -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'; diff --git a/plugins/periskop-backend/src/run.ts b/plugins/periskop-backend/src/run.ts new file mode 100644 index 0000000000..0a3ed2b7f0 --- /dev/null +++ b/plugins/periskop-backend/src/run.ts @@ -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); +}); diff --git a/plugins/periskop-backend/src/service/router.test.ts b/plugins/periskop-backend/src/service/router.test.ts new file mode 100644 index 0000000000..687255c6e0 --- /dev/null +++ b/plugins/periskop-backend/src/service/router.test.ts @@ -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' }); + }); + }); +}); diff --git a/plugins/periskop-backend/src/service/router.ts b/plugins/periskop-backend/src/service/router.ts new file mode 100644 index 0000000000..4f8ae12c31 --- /dev/null +++ b/plugins/periskop-backend/src/service/router.ts @@ -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 { + 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; +} diff --git a/plugins/periskop-backend/src/service/standaloneServer.ts b/plugins/periskop-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..635b139ccf --- /dev/null +++ b/plugins/periskop-backend/src/service/standaloneServer.ts @@ -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 { + 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(); diff --git a/plugins/periskop-backend/src/setupTests.ts b/plugins/periskop-backend/src/setupTests.ts new file mode 100644 index 0000000000..813cdeaae3 --- /dev/null +++ b/plugins/periskop-backend/src/setupTests.ts @@ -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 {}; diff --git a/plugins/periskop-backend/src/types.ts b/plugins/periskop-backend/src/types.ts new file mode 100644 index 0000000000..084ba52620 --- /dev/null +++ b/plugins/periskop-backend/src/types.ts @@ -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; +} diff --git a/plugins/periskop/.eslintrc.js b/plugins/periskop/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/periskop/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/periskop/README.md b/plugins/periskop/README.md new file mode 100644 index 0000000000..35e86f083d --- /dev/null +++ b/plugins/periskop/README.md @@ -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 = ( + + {/* other tabs... */} + + + + + + + +``` + +4. [Setup the `app-config.yaml`](#instances) `periskop` block + +5. Annotate entities with the periskop service name + +```yaml +annotations: + periskop.io/service-name: '' +``` + +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: + url: +``` diff --git a/plugins/periskop/api-report.md b/plugins/periskop/api-report.md new file mode 100644 index 0000000000..2cf84faccf --- /dev/null +++ b/plugins/periskop/api-report.md @@ -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 +/// + +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; + getInstanceNames(): string[]; +} + +// @public (undocumented) +export type PeriskopApiOptions = { + discoveryApi: DiscoveryApi; + configApi: ConfigApi; +}; + +// @public (undocumented) +export const periskopApiRef: ApiRef; + +// @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; + // (undocumented) + getInstanceNames(): string[]; +} + +// @public (undocumented) +export const periskopPlugin: BackstagePlugin<{}, {}>; + +// @public (undocumented) +export interface RequestHeaders { + // (undocumented) + [k: string]: string; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/periskop/config.d.ts b/plugins/periskop/config.d.ts new file mode 100644 index 0000000000..dfb914cd4f --- /dev/null +++ b/plugins/periskop/config.d.ts @@ -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; + }>; + }; +} diff --git a/plugins/periskop/dev/index.tsx b/plugins/periskop/dev/index.tsx new file mode 100644 index 0000000000..b752f7f8b0 --- /dev/null +++ b/plugins/periskop/dev/index.tsx @@ -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(); diff --git a/plugins/periskop/docs/periskop-plugin-screenshot.png b/plugins/periskop/docs/periskop-plugin-screenshot.png new file mode 100644 index 0000000000..9971f41992 Binary files /dev/null and b/plugins/periskop/docs/periskop-plugin-screenshot.png differ diff --git a/plugins/periskop/package.json b/plugins/periskop/package.json new file mode 100644 index 0000000000..8045b94133 --- /dev/null +++ b/plugins/periskop/package.json @@ -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" +} diff --git a/plugins/periskop/src/api/index.ts b/plugins/periskop/src/api/index.ts new file mode 100644 index 0000000000..4171d55ae2 --- /dev/null +++ b/plugins/periskop/src/api/index.ts @@ -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; +} + +/** + * 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 { + 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(); + } +} diff --git a/plugins/periskop/src/components/EntityPeriskopErrorsCard/EntityPeriskopErrorsCard.tsx b/plugins/periskop/src/components/EntityPeriskopErrorsCard/EntityPeriskopErrorsCard.tsx new file mode 100644 index 0000000000..cd4aa6da0f --- /dev/null +++ b/plugins/periskop/src/components/EntityPeriskopErrorsCard/EntityPeriskopErrorsCard.tsx @@ -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 {error.aggregation_key}; +}; + +const renderSeverity = (severity: string): React.ReactNode => { + if (severity.toLocaleLowerCase('en-US') === 'warning') { + return {severity}; + } else if (severity.toLocaleLowerCase('en-US') === 'error') { + return {severity}; + } + return {severity}; +}; + +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( + instanceNames[0], + ); + const { + value: aggregatedErrors, + loading, + error, + } = useAsync(async (): Promise => { + return periskopApi.getErrors(instanceOption, entityPeriskopName); + }, [instanceOption]); + + if (loading) { + return ; + } else if (error) { + return ; + } + + const columns: TableColumn[] = [ + { + 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 = ( +