Merge pull request #9605 from moltenice-forks/airbrake-connect

Make the Airbrake plugin useable
This commit is contained in:
Fredrik Adelöw
2022-02-21 19:49:03 +01:00
committed by GitHub
25 changed files with 278 additions and 93 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-airbrake': minor
'@backstage/plugin-airbrake-backend': minor
---
This marks the first release where the Airbrake plugin is useable. Airbrake frontend and Airbrake backend work with each other. Currently just a summary of the latest Airbrakes is shown on Backstage.
+2
View File
@@ -3,6 +3,8 @@ accessors
addon
addons
Airbrake
Airbrakes
airbrake
Anddddd
Apdex
api
+13 -8
View File
@@ -1,16 +1,21 @@
# airbrake-backend
Welcome to the airbrake-backend backend plugin!
The Airbrake backend plugin provides a simple proxy to the Airbrake API while hiding away the secret API key from the frontend.
_This plugin was created through the Backstage CLI_
## How to use
## Getting started
See the [Airbrake plugin instructions](../airbrake/README.md#how-to-use).
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/airbrake-backend](http://localhost:7007/airbrake-backend).
## Local Development
Here is an example endpoint: http://localhost:7007/airbrake-backend/health
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development.
1. Set the environment variable `AIRBRAKE_API_KEY` with your [API key](https://airbrake.io/docs/api/#authentication).
2. Run this plugin in standalone mode by running `yarn start`. The configuration is already setup in the root [`app-config.yaml`](../../app-config.yaml) to pick up your API key from the environment variable above.
Access it from http://localhost:7007/api/airbrake. Or use the Airbrake plugin which will talk to it automatically.
Here are some example endpoints:
- http://localhost:7007/api/airbrake/health
- http://localhost:7007/api/airbrake/api/v4/projects
+5 -1
View File
@@ -6,6 +6,7 @@
import { Config } from '@backstage/config';
import express from 'express';
import { Logger as Logger_2 } from 'winston';
import * as winston from 'winston';
// @public
export interface AirbrakeConfig {
@@ -16,7 +17,10 @@ export interface AirbrakeConfig {
export function createRouter(options: RouterOptions): Promise<express.Router>;
// @public
export function extractAirbrakeConfig(config: Config): AirbrakeConfig;
export function extractAirbrakeConfig(
config: Config,
logger: winston.Logger,
): AirbrakeConfig;
// @public
export interface RouterOptions {
+1
View File
@@ -19,6 +19,7 @@ export interface Config {
airbrake: {
/**
* The API Key to authenticate requests. More details on how to get this here: https://airbrake.io/docs/api/#authentication
* @visibility secret
*/
apiKey: string;
};
+1 -15
View File
@@ -40,19 +40,5 @@
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts",
"jest": {
"coverageThreshold": {
"global": {
"functions": 100,
"lines": 100,
"statements": 100
}
},
"coveragePathIgnorePatterns": [
"standaloneServer.ts",
"index.ts",
"run.ts"
]
}
"configSchema": "config.d.ts"
}
@@ -0,0 +1,67 @@
/*
* 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 { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { extractAirbrakeConfig } from './ExtractAirbrakeConfig';
import * as winston from 'winston';
describe('ExtractAirbrakeConfig', () => {
let oldProcessEnv: NodeJS.ProcessEnv;
let voidLogger: winston.Logger;
beforeEach(() => {
oldProcessEnv = process.env;
voidLogger = getVoidLogger();
});
it('gets the API key', () => {
const config = new ConfigReader({
airbrake: {
apiKey: 'fakeApiKey',
},
});
const airbrakeConfig = extractAirbrakeConfig(config, voidLogger);
expect(airbrakeConfig.apiKey).toStrictEqual('fakeApiKey');
});
it('does not fail in development', () => {
process.env = {
...oldProcessEnv,
NODE_ENV: 'development',
};
const config = new ConfigReader({});
expect(() => extractAirbrakeConfig(config, voidLogger)).not.toThrow();
const airbrakeConfig = extractAirbrakeConfig(config, voidLogger);
expect(airbrakeConfig.apiKey).toStrictEqual('');
});
it('fails in production', () => {
process.env = {
...oldProcessEnv,
NODE_ENV: 'production',
};
const config = new ConfigReader({});
expect(() => extractAirbrakeConfig(config, voidLogger)).toThrow();
});
afterEach(() => {
process.env = { ...oldProcessEnv };
});
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { Config } from '@backstage/config';
import * as winston from 'winston';
/**
* The configuration needed for the airbrake-backend plugin
@@ -33,9 +34,27 @@ export interface AirbrakeConfig {
* @public
*
* @param config - The config object to extract from
* @param logger - THe logger object
*/
export function extractAirbrakeConfig(config: Config): AirbrakeConfig {
return {
apiKey: config.getString('airbrake.apiKey'),
};
export function extractAirbrakeConfig(
config: Config,
logger: winston.Logger,
): AirbrakeConfig {
try {
return {
apiKey: config.getString('airbrake.apiKey'),
};
} catch (e) {
if (process.env.NODE_ENV !== 'development') {
throw e;
} else {
logger.warn(
'Airbrake config missing, Airbrake plugin will probably not work',
e,
);
return {
apiKey: '',
};
}
}
}
@@ -24,23 +24,26 @@ import {
RouterOptions,
} from './router';
import { AirbrakeConfig, extractAirbrakeConfig } from '../config';
import * as winston from 'winston';
describe('createRouter', () => {
let app: express.Express;
let airbrakeConfig: AirbrakeConfig;
let voidLogger: winston.Logger;
beforeEach(async () => {
jest.resetAllMocks();
voidLogger = getVoidLogger();
const config = new ConfigReader({
airbrake: {
apiKey: 'fakeApiKey',
},
});
airbrakeConfig = extractAirbrakeConfig(config);
airbrakeConfig = extractAirbrakeConfig(config, voidLogger);
const router = await createRouter({
logger: getVoidLogger(),
logger: voidLogger,
airbrakeConfig,
});
app = express().use(router);
@@ -58,7 +61,7 @@ describe('createRouter', () => {
describe('GET /api', () => {
it('appends the API Key properly with no other url parameters', () => {
const options: RouterOptions = {
logger: getVoidLogger(),
logger: voidLogger,
airbrakeConfig,
};
const pathRewrite = generateAirbrakePathRewrite(options) as (
@@ -72,7 +75,7 @@ describe('createRouter', () => {
it('appends the API Key properly despite there being other URL parameters', () => {
const options: RouterOptions = {
logger: getVoidLogger(),
logger: voidLogger,
airbrakeConfig,
};
const pathRewrite = generateAirbrakePathRewrite(options) as (
@@ -32,9 +32,9 @@ export interface ServerOptions {
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'airbrake-backend-backend' });
const logger = options.logger.child({ service: 'airbrake-backend' });
const config = await loadBackendConfig({ logger, argv: process.argv });
const airbrakeConfig = extractAirbrakeConfig(config);
const airbrakeConfig = extractAirbrakeConfig(config, logger);
logger.debug('Starting application server...');
const router = await createRouter({
logger,
@@ -43,9 +43,13 @@ export async function startStandaloneServer(
let service = createServiceBuilder(module)
.setPort(options.port)
.addRouter('/airbrake-backend', router);
.addRouter('/api/airbrake', router);
if (options.enableCors) {
logger.info('CORS is enabled, limiting to localhost with port 3000');
service = service.enableCors({ origin: 'http://localhost:3000' });
} else {
logger.info('CORS is disabled, allowing all origins');
service = service.enableCors({ origin: '*' });
}
return await service.start().catch(err => {
+69 -7
View File
@@ -1,13 +1,75 @@
# Airbrake
Welcome to the Airbrake plugin!
The Airbrake plugin provides connectivity between Backstage and Airbrake (https://airbrake.io/).
This is a plugin providing connectivity between Backstage and Airbrake (https://airbrake.io/).
## How to use
_This plugin is currently not fit for use as it is work in progress_
1. Install the Frontend plugin:
## Getting started
```bash
# From your Backstage root directory
cd packages/app
yarn add @backstage/plugin-airbrake
```
You can serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.
2. Install the Backend plugin:
```bash
# From your Backstage root directory
cd packages/backend
yarn add @backstage/plugin-airbrake-backend
```
3. Add the `EntityAirbrakeContent` to `packages/app/src/components/catalog/EntityPage.tsx`:
```typescript jsx
import { EntityAirbrakeContent } from '@backstage/plugin-airbrake';
const serviceEntityPage = (
<EntityLayoutWrapper>
<EntityLayout.Route path="/airbrake" title="Airbrake">
<EntityAirbrakeContent />
</EntityLayout.Route>
</EntityLayoutWrapper>
);
```
4. Setup the Backend code in `packages/backend/src/index.ts`:
```typescript
import {
createRouter as createAirbrakeRouter,
extractAirbrakeConfig,
} from '@backstage/plugin-airbrake-backend';
async function main() {
//... After const config = await loadBackendConfig({ ...
const airbrakeRouter = await createAirbrakeRouter({
logger,
airbrakeConfig: extractAirbrakeConfig(config),
});
const service = createServiceBuilder(module)
// ... Add the airbrakeRouter here
.addRouter('/api/airbrake', airbrakeRouter);
}
```
5. Add this config as a top level section in your `app-config.yaml`:
```yaml
airbrake:
apiKey: ${AIRBRAKE_API_KEY}
```
6. Set an environment variable `AIRBRAKE_API_KEY` with your [API key](https://airbrake.io/docs/api/#authentication)
before starting Backstage backend.
## Local Development
Start this plugin in standalone mode by running `yarn start`. This method of serving the plugin provides quicker
iteration speed and a faster startup and hot reloads. It is only meant for local development, and the setup for it can
be found inside the [/dev](./dev) directory.
> A mock API will be used to run it in standalone. If you want to talk to the real API [follow the instructions to start up Airbrake Backend in standalone](../airbrake-backend/README.md#local-development).
+2 -8
View File
@@ -8,9 +8,7 @@
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { RouteRef } from '@backstage/core-plugin-api';
// Warning: (ae-missing-release-tag) "airbrakePlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
export const airbrakePlugin: BackstagePlugin<
{
root: RouteRef<undefined>;
@@ -18,10 +16,6 @@ export const airbrakePlugin: BackstagePlugin<
{}
>;
// Warning: (ae-missing-release-tag) "EntityAirbrakeContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
export const EntityAirbrakeContent: () => JSX.Element;
// (No @packageDocumentation comment for this package)
```
@@ -71,12 +71,6 @@ export const ApiBar = () => {
value.setProjectId?.(parseInt(e.target.value, 10) || undefined)
}
/>
<TextField
label="API Key"
variant="outlined"
defaultValue={value.apiKey}
onChange={e => value.setApiKey?.(e.target.value)}
/>
</MuiThemeProvider>
</div>
)}
@@ -23,23 +23,18 @@ import React, {
interface ContextInterface {
projectId?: number;
setProjectId?: Dispatch<SetStateAction<number | undefined>>;
apiKey?: string;
setApiKey?: Dispatch<SetStateAction<string>>;
}
export const Context = React.createContext<ContextInterface>({});
export const ContextProvider = ({ children }: PropsWithChildren<{}>) => {
const [projectId, setProjectId] = useState<number>();
const [apiKey, setApiKey] = useState<string>('');
return (
<Context.Provider
value={{
projectId,
setProjectId,
apiKey,
setApiKey,
}}
>
{children}
+10 -3
View File
@@ -19,13 +19,14 @@ import { TestApiProvider } from '@backstage/test-utils';
import { airbrakePlugin, EntityAirbrakeContent } from '../src';
import {
airbrakeApiRef,
localDiscoveryApi,
MockAirbrakeApi,
ProductionAirbrakeApi,
} from '../src/api';
import { ApiBar } from './components/ApiBar';
import { Content, Header, Page } from '@backstage/core-components';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { createEntity } from '../src/api/mock/MockEntity';
import { createEntity } from '../src/api';
import CloudOffIcon from '@material-ui/icons/CloudOff';
import CloudIcon from '@material-ui/icons/Cloud';
import { Context, ContextProvider } from './components/ContextProvider';
@@ -53,7 +54,10 @@ createDevApp()
element: (
<ContextProvider>
<Page themeId="tool">
<Header title="Airbrake demo application" subtitle="Real API">
<Header
title="Airbrake demo application"
subtitle="Real API (The Airbrake backend plugin must be running for this to work)"
>
<ApiBar />
</Header>
<Content>
@@ -61,7 +65,10 @@ createDevApp()
{value => (
<TestApiProvider
apis={[
[airbrakeApiRef, new ProductionAirbrakeApi(value.apiKey)],
[
airbrakeApiRef,
new ProductionAirbrakeApi(localDiscoveryApi),
],
]}
>
<EntityProvider entity={createEntity(value.projectId)}>
+1 -11
View File
@@ -42,7 +42,6 @@
"devDependencies": {
"@backstage/app-defaults": "^0.1.8",
"@backstage/cli": "^0.14.0",
"@backstage/core-app-api": "^0.5.3",
"@backstage/dev-utils": "^0.2.22",
"@backstage/test-utils": "^0.2.5",
"@testing-library/jest-dom": "^5.10.1",
@@ -57,14 +56,5 @@
},
"files": [
"dist"
],
"jest": {
"coverageThreshold": {
"global": {
"functions": 100,
"lines": 100,
"statements": 100
}
}
}
]
}
@@ -19,21 +19,19 @@ import { rest } from 'msw';
import mockGroupsData from './mock/airbrakeGroupsApiMock.json';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/test-utils';
import { localDiscoveryApi } from './mock';
describe('The production Airbrake API', () => {
const productionApi = new ProductionAirbrakeApi('fakeApiKey');
const productionApi = new ProductionAirbrakeApi(localDiscoveryApi);
const worker = setupServer();
setupRequestMockHandlers(worker);
it('fetches groups using the provided project ID', async () => {
worker.use(
rest.get(
'https://api.airbrake.io/api/v4/projects/123456/groups',
(req, res, ctx) => {
if (req.url.searchParams.get('key') === 'fakeApiKey') {
return res(ctx.status(200), ctx.json(mockGroupsData));
}
return res(ctx.status(401));
'http://localhost:7007/api/airbrake/api/v4/projects/123456/groups',
(_, res, ctx) => {
return res(ctx.status(200), ctx.json(mockGroupsData));
},
),
);
@@ -46,7 +44,7 @@ describe('The production Airbrake API', () => {
it('throws if fetching groups was unsuccessful', async () => {
worker.use(
rest.get(
'https://api.airbrake.io/api/v4/projects/123456/groups',
'http://localhost:7007/api/airbrake/api/v4/projects/123456/groups',
(_, res, ctx) => {
return res(ctx.status(500));
},
+4 -2
View File
@@ -16,12 +16,14 @@
import { Groups } from './airbrakeGroups';
import { AirbrakeApi } from './AirbrakeApi';
import { DiscoveryApi } from '@backstage/core-plugin-api';
export class ProductionAirbrakeApi implements AirbrakeApi {
constructor(private readonly apiKey?: string) {}
constructor(private readonly discoveryApi: DiscoveryApi) {}
async fetchGroups(projectId: string): Promise<Groups> {
const apiUrl = `https://api.airbrake.io/api/v4/projects/${projectId}/groups?key=${this.apiKey}`;
const baseUrl = await this.discoveryApi.getBaseUrl('airbrake');
const apiUrl = `${baseUrl}/api/v4/projects/${projectId}/groups`;
const response = await fetch(apiUrl);
@@ -0,0 +1,22 @@
/*
* 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 { DiscoveryApi } from '@backstage/core-plugin-api';
export const localDiscoveryApi: DiscoveryApi = {
async getBaseUrl(pluginId: string): Promise<string> {
return `http://localhost:7007/api/${pluginId}`;
},
};
+3 -1
View File
@@ -14,4 +14,6 @@
* limitations under the License.
*/
export { MockAirbrakeApi } from './MockApi';
export * from './MockApi';
export * from './MockEntity';
export * from './LocalDiscoveryApi';
@@ -23,9 +23,10 @@ import {
setupRequestMockHandlers,
TestApiProvider,
} from '@backstage/test-utils';
import { createEntity } from '../../api/mock/MockEntity';
import { createEntity } from '../../api';
import {
airbrakeApiRef,
localDiscoveryApi,
MockAirbrakeApi,
ProductionAirbrakeApi,
} from '../../api';
@@ -65,7 +66,7 @@ describe('EntityAirbrakeContent', () => {
it('states that an error occurred if the API call fails', async () => {
worker.use(
rest.get(
'https://api.airbrake.io/api/v4/projects/123/groups',
'http://localhost:7007/api/airbrake/api/v4/projects/123/groups',
(_, res, ctx) => {
return res(ctx.status(500));
},
@@ -76,7 +77,7 @@ describe('EntityAirbrakeContent', () => {
const widget = await renderInTestApp(
<TestApiProvider
apis={[
[airbrakeApiRef, new ProductionAirbrakeApi('fakeApiKey')],
[airbrakeApiRef, new ProductionAirbrakeApi(localDiscoveryApi)],
[errorApiRef, mockErrorApi],
]}
>
+1 -1
View File
@@ -18,7 +18,7 @@ import { EntityAirbrakeContent } from './extensions';
import { Route } from 'react-router';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { airbrakeApiRef, MockAirbrakeApi } from './api';
import { createEntity } from './api/mock/MockEntity';
import { createEntity } from './api';
import { EntityProvider } from '@backstage/plugin-catalog-react';
describe('The Airbrake entity', () => {
+5
View File
@@ -20,6 +20,11 @@ import { airbrakePlugin } from './plugin';
import { createRoutableExtension } from '@backstage/core-plugin-api';
import { rootRouteRef } from './routes';
/**
* This is the widget that shows up on a component page
*
* @public
*/
export const EntityAirbrakeContent = airbrakePlugin.provide(
createRoutableExtension({
name: 'EntityAirbrakeContent',
+7
View File
@@ -13,5 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* The Airbrake plugin provides connectivity between Backstage and Airbrake (https://airbrake.io/).
*
* @packageDocumentation
*/
export { airbrakePlugin } from './plugin';
export { EntityAirbrakeContent } from './extensions';
+12 -3
View File
@@ -13,18 +13,27 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiFactory, createPlugin } from '@backstage/core-plugin-api';
import {
createApiFactory,
createPlugin,
discoveryApiRef,
} from '@backstage/core-plugin-api';
import { rootRouteRef } from './routes';
import { airbrakeApiRef, ProductionAirbrakeApi } from './api';
/**
* The Airbrake plugin instance
*
* @public
*/
export const airbrakePlugin = createPlugin({
id: 'airbrake',
apis: [
createApiFactory({
api: airbrakeApiRef,
deps: {},
factory: () => new ProductionAirbrakeApi(),
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) => new ProductionAirbrakeApi(discoveryApi),
}),
],
routes: {