Merge pull request #18892 from wss-rbunning/allow-pagination-in-newrelic-plugin

Add pagination support to the newrelic plugin
This commit is contained in:
Fredrik Adelöw
2023-08-10 13:04:20 +02:00
committed by GitHub
8 changed files with 484 additions and 22 deletions
+4
View File
@@ -18,6 +18,8 @@ APIs.
target: https://api.newrelic.com/v2
headers:
X-Api-Key: ${NEW_RELIC_REST_API_KEY}
allowedHeaders:
- link
```
There is some types of api key on new relic, to this use must be `User` type of key, In your production deployment of Backstage, you would also need to ensure that
@@ -33,6 +35,8 @@ APIs.
'/newrelic/apm/api':
headers:
X-Api-Key: NRRA-YourActualApiKey
allowedHeaders:
- link
```
Read more about how to find or generate this key in
+4 -1
View File
@@ -39,6 +39,7 @@
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
"parse-link-header": "^2.0.0",
"react-use": "^17.2.4"
},
"peerDependencies": {
@@ -47,6 +48,7 @@
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/dev-utils": "workspace:^",
@@ -56,9 +58,10 @@
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^14.0.0",
"@types/node": "^16.11.26",
"@types/parse-link-header": "^2.0.1",
"@types/react": "^16.13.1 || ^17.0.0",
"cross-fetch": "^3.1.5",
"msw": "^1.0.0"
"msw": "^1.2.3"
},
"files": [
"dist"
+384
View File
@@ -0,0 +1,384 @@
/*
* Copyright 2023 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 { NewRelicApplication, NewRelicClient } from '.';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils';
const mockedDiscoveryApi: DiscoveryApi = {
getBaseUrl: async () => 'https://test.test',
};
beforeEach(() => {
jest.resetAllMocks();
});
describe('NewRelicClient', () => {
const server = setupServer();
setupRequestMockHandlers(server);
beforeEach(() => {
server.resetHandlers();
});
test.each([
['https://test.test/BASEPATH/apm/api/applications.json', '/BASEPATH'],
['https://test.test/BASEPATH2/apm/api/applications.json', '/BASEPATH2'],
['https://test.testBASEPATH3/apm/api/applications.json', 'BASEPATH3'],
['https://test.test/newrelic/apm/api/applications.json', undefined],
])(
'It correctly forms the request url (%p) when proxyPathBase is %p',
async (expectedUrl, basePathOverride) => {
server.use(
rest.get(expectedUrl, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ applications: [] })),
),
);
const mockedFetchApi = new MockFetchApi();
const fetchSpy = jest.spyOn(mockedFetchApi, 'fetch');
const client = new NewRelicClient({
discoveryApi: mockedDiscoveryApi,
fetchApi: mockedFetchApi,
proxyPathBase: basePathOverride,
});
await client.getApplications();
expect(fetchSpy).toHaveBeenCalledWith(expectedUrl);
},
);
it('Correctly reads all pages of results and returns the expected results', async () => {
const mockedApplicationOne: NewRelicApplication = {
id: 1,
application_summary: {
apdex_score: 0,
error_rate: 100,
host_count: 500,
instance_count: 5000,
response_time: 20,
throughput: 500000,
},
name: 'Testing Application #1',
language: 'en-us',
health_status: 'Failing',
reporting: true,
settings: {
app_apdex_threshold: 0,
end_user_apdex_threshold: 0,
enable_real_user_monitoring: true,
use_server_side_config: true,
},
};
const mockedApplicationTwo: NewRelicApplication = {
id: 2,
application_summary: {
apdex_score: -900,
error_rate: 0,
host_count: 0,
instance_count: 0,
response_time: 0,
throughput: 0,
},
name: 'Testing Application #2',
language: 'en-us',
health_status: 'Working',
reporting: true,
settings: {
app_apdex_threshold: 0,
end_user_apdex_threshold: 0,
enable_real_user_monitoring: true,
use_server_side_config: true,
},
};
const mockedApplicationThree: NewRelicApplication = {
id: 3,
application_summary: {
apdex_score: -900,
error_rate: 0,
host_count: 0,
instance_count: 0,
response_time: 0,
throughput: 0,
},
name: 'Testing Application #3',
language: 'en-us',
health_status: 'Waiting',
reporting: false,
settings: {
app_apdex_threshold: 1000,
end_user_apdex_threshold: 500,
enable_real_user_monitoring: false,
use_server_side_config: false,
},
};
const queryToRequestData = new Map<
string | null,
{ link?: string | string[]; apps: NewRelicApplication[] }
>([
[
null,
{
link: [
'<https://next.page/page2?page=2>; rel="next"',
'<https://badsite.dontgohere>; rel="bad"',
],
apps: [mockedApplicationOne],
},
],
[
'2',
{
link: '<https://next.page/page3?page=3>; rel="next",',
apps: [],
},
],
[
'3',
{
apps: [mockedApplicationTwo, mockedApplicationThree],
},
],
]);
const mockedFetchApi = new MockFetchApi();
const fetchSpy = jest.spyOn(mockedFetchApi, 'fetch');
server.use(
rest.get(
'https://test.test/newrelic/apm/api/applications.json',
(req, res, ctx) => {
const nextPageNumber = req.url.searchParams.get('page');
const requestData = queryToRequestData.get(nextPageNumber) ?? {
apps: [],
};
const { link, apps: applications } = requestData;
const statusTransform = ctx.status(200);
const responseBody = ctx.json({ applications });
if (!!link) {
return res(statusTransform, ctx.set({ link }), responseBody);
}
return res(statusTransform, responseBody);
},
),
);
const client = new NewRelicClient({
discoveryApi: mockedDiscoveryApi,
fetchApi: mockedFetchApi,
});
const actual = await client.getApplications();
const expected = {
applications: [
mockedApplicationOne,
mockedApplicationTwo,
mockedApplicationThree,
],
};
expect(fetchSpy).toHaveBeenCalledTimes(3);
expect(fetchSpy).toHaveBeenCalledWith(
'https://test.test/newrelic/apm/api/applications.json',
);
expect(fetchSpy).toHaveBeenCalledWith(
'https://test.test/newrelic/apm/api/applications.json?page=2',
);
expect(fetchSpy).toHaveBeenCalledWith(
'https://test.test/newrelic/apm/api/applications.json?page=3',
);
expect(actual).toStrictEqual(expected);
});
test.each([['Link'], ['LINK'], ['lINK']])(
'It does not attempt pagination when the link header name is invalid (%p)',
async linkHeaderName => {
const mockedFetchApi = new MockFetchApi();
const fetchSpy = jest.spyOn(mockedFetchApi, 'fetch');
server.use(
rest.get(
'https://test.test/newrelic/apm/api/applications.json',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set(
linkHeaderName,
'<https://test.test/badroute>; rel="next"',
),
ctx.json({ applications: [] }),
),
),
rest.get('https://test.test/badroute', () => {
throw new Error(
'NewRelicClient attempted to paginate when it should not have',
);
}),
);
const client = new NewRelicClient({
discoveryApi: mockedDiscoveryApi,
fetchApi: mockedFetchApi,
});
await client.getApplications();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith(
'https://test.test/newrelic/apm/api/applications.json',
);
},
);
test.each([
[''],
['<> rel=""'],
['<>; rel=""'],
['<https://next.page>; rel=""'],
['<>; rel:"value"'],
['<https://next.page>; rel: "next"'],
['ABCDE'],
['<https://next.page/page3?page=>; rel="next",'],
])(
'It does not attempt pagination when the link header value is invalid (%p)',
async linkHeaderValue => {
const mockedFetchApi = new MockFetchApi();
const fetchSpy = jest.spyOn(mockedFetchApi, 'fetch');
server.use(
rest.get(
'https://test.test/newrelic/apm/api/applications.json',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('link', linkHeaderValue),
ctx.json({ applications: [] }),
),
),
rest.get('https://test.test/badroute', () => {
throw new Error(
'NewRelicClient attempted to paginate when it should not have',
);
}),
);
const client = new NewRelicClient({
discoveryApi: mockedDiscoveryApi,
fetchApi: mockedFetchApi,
});
await client.getApplications();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith(
'https://test.test/newrelic/apm/api/applications.json',
);
},
);
test.each([
['Error communicating with New Relic: Not Found', 404, JSON.stringify({})],
[
'Error communicating with New Relic: ERROR TITLE',
404,
JSON.stringify({
error: {
title: 'ERROR TITLE',
},
}),
],
[
'Error communicating with New Relic: Internal Server Error',
500,
JSON.stringify(undefined),
],
[
'Error communicating with New Relic: Internal Server Error',
500,
JSON.stringify(null),
],
[
'Error communicating with New Relic: Internal Server Error',
500,
'<invalid></invalid',
],
])(
'It throws this error: %p when the status code is %p and the body is %j',
async (expectedErrorMessage, statusCode, body) => {
server.use(
rest.get(
'https://test.test/newrelic/apm/api/applications.json',
(_, res, ctx) => res(ctx.status(statusCode), ctx.body(body)),
),
);
const client = new NewRelicClient({
discoveryApi: mockedDiscoveryApi,
fetchApi: new MockFetchApi(),
});
await expect(client.getApplications()).rejects.toThrow(
expectedErrorMessage,
);
},
);
it('Throws an error when the body is invalid json but the status code is 200', async () => {
server.use(
rest.get(
'https://test.test/newrelic/apm/api/applications.json',
(_, res, ctx) => res(ctx.status(200), ctx.body('<Invalid></Invalid')),
),
);
const client = new NewRelicClient({
discoveryApi: mockedDiscoveryApi,
fetchApi: new MockFetchApi(),
});
await expect(client.getApplications()).rejects.toThrow();
});
it('Generates the base url only once', async () => {
const getBaseUrlSpy = jest.spyOn(mockedDiscoveryApi, 'getBaseUrl');
server.use(
rest.get(
'https://test.test/newrelic/apm/api/applications.json',
(_, res, ctx) => res(ctx.status(200), ctx.json({ applications: [] })),
),
);
const client = new NewRelicClient({
discoveryApi: mockedDiscoveryApi,
fetchApi: new MockFetchApi(),
});
await client.getApplications();
await client.getApplications();
await client.getApplications();
await client.getApplications();
expect(getBaseUrlSpy).toHaveBeenCalledTimes(1);
});
});
+56 -16
View File
@@ -14,7 +14,13 @@
* limitations under the License.
*/
import { createApiRef, DiscoveryApi } from '@backstage/core-plugin-api';
import {
createApiRef,
DiscoveryApi,
FetchApi,
} from '@backstage/core-plugin-api';
import parseLinkHeader from 'parse-link-header';
export type NewRelicApplication = {
id: number;
@@ -61,6 +67,7 @@ const DEFAULT_PROXY_PATH_BASE = '/newrelic';
type Options = {
discoveryApi: DiscoveryApi;
fetchApi: FetchApi;
/**
* Path to use for requests via the proxy, defaults to /newrelic
*/
@@ -71,39 +78,72 @@ export interface NewRelicApi {
getApplications(): Promise<NewRelicApplications>;
}
interface NewRelicPageReadResult {
nextPageUrl: string | undefined;
applicationsFromReadPage: NewRelicApplication[];
}
export class NewRelicClient implements NewRelicApi {
private readonly discoveryApi: DiscoveryApi;
private readonly fetchApi: FetchApi;
private readonly proxyPathBase: string;
private baseUrl: string;
constructor(options: Options) {
this.discoveryApi = options.discoveryApi;
this.fetchApi = options.fetchApi;
this.proxyPathBase = options.proxyPathBase ?? DEFAULT_PROXY_PATH_BASE;
this.baseUrl = '';
}
async getApplications(): Promise<NewRelicApplications> {
const url = await this.getApiUrl('apm', 'applications.json');
const response = await fetch(url);
let responseJson;
try {
responseJson = await response.json();
} catch (e) {
responseJson = { applications: [] };
if (!this.baseUrl) {
const proxyUrl = await this.discoveryApi.getBaseUrl('proxy');
this.baseUrl = `${proxyUrl}${this.proxyPathBase}/apm/api/applications.json`;
}
if (response.status !== 200) {
const applications: NewRelicApplication[] = [];
let targetUrl = this.baseUrl;
do {
const { nextPageUrl, applicationsFromReadPage } =
await this.fetchNewRelic(targetUrl);
targetUrl = nextPageUrl ?? '';
applications.push(...applicationsFromReadPage);
} while (!!targetUrl);
return { applications };
}
private async fetchNewRelic(
targetUrl: string,
): Promise<NewRelicPageReadResult> {
const response = await this.fetchApi.fetch(targetUrl);
if (!response.ok) {
let specificErrorTitle = undefined;
try {
specificErrorTitle = (await response.json())?.error?.title;
} catch (e) {
/* empty */
}
throw new Error(
`Error communicating with New Relic: ${
responseJson?.error?.title || response.statusText
specificErrorTitle || response.statusText
}`,
);
}
return responseJson;
}
const readResponse = (await response.json()) as NewRelicApplications;
const linkHeader = response.headers.get('link');
const parseResult = parseLinkHeader(linkHeader);
const nextPageNumber = parseResult?.next?.page;
private async getApiUrl(product: string, path: string) {
const proxyUrl = await this.discoveryApi.getBaseUrl('proxy');
return `${proxyUrl}${this.proxyPathBase}/${product}/api/${path}`;
return {
nextPageUrl: nextPageNumber && `${this.baseUrl}?page=${nextPageNumber}`,
applicationsFromReadPage: readResponse.applications,
};
}
}
+7 -2
View File
@@ -20,6 +20,7 @@ import {
createPlugin,
createRouteRef,
discoveryApiRef,
fetchApiRef,
createRoutableExtension,
} from '@backstage/core-plugin-api';
@@ -33,8 +34,12 @@ export const newRelicPlugin = createPlugin({
apis: [
createApiFactory({
api: newRelicApiRef,
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) => new NewRelicClient({ discoveryApi }),
deps: {
discoveryApi: discoveryApiRef,
fetchApi: fetchApiRef,
},
factory: ({ discoveryApi, fetchApi }) =>
new NewRelicClient({ discoveryApi, fetchApi }),
}),
],
routes: {