Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[APM] Improve type safety across application boundaries #42252

Closed

Conversation

sorenlouv
Copy link
Member

@sorenlouv sorenlouv commented Jul 30, 2019

This PR does two things

  • Move the exported type definition to the route layer
  • Enables type checking of API query params between frontend and backend

Problem:
We want to have type safety between the client and the server. We currently infer the response of a rest call by referring to the exported type on the server side:

callApi<SomeTypeFromBackend>(...)

There are are two problems with this:

  • The exported type SomeTypeFromBackend is exported from deep inside the server application, and thus does take possible transformations at the routing layer into consideration
  • The exported type only contains information about the return type - nothing else about the API (eg query params, method etc)

Solution
Instead of referring to a specific return type the client should simply refer to the route method it is calling:

callApi<typeof transaction_distribution_route>(...)

transaction_distribution_route is the name of the route. This is at the very edge of the server side application so we can be much more confident that the return type of this method corresponds to what is actually transmitted over the wire.
Additionally, since we are now passing the type of the backend route we can infer more than just the return type. Currently I've added inference of query params but in the future we might also be able to infer method and perhaps do some magic with path.

I've only converted the transaction_groups APIs. I'd like to get feedback before converting the remaining APIs. I don't expect that to take more than half a day.

@sorenlouv sorenlouv requested a review from a team as a code owner July 30, 2019 13:22
@sorenlouv sorenlouv added release_note:skip Skip the PR/issue when compiling release notes Team:APM All issues that need APM UI Team support labels Jul 30, 2019
@elasticmachine
Copy link
Contributor

Pinging @elastic/apm-ui

@elasticmachine
Copy link
Contributor

💔 Build Failed

@@ -24,7 +24,7 @@ export async function loadTransactionList({
transactionType: string;
uiFilters: UIFilters;
}) {
return await callApi<TransactionGroupListAPIResponse>({
return await callApi<typeof transactionGroupListRoute>({
Copy link
Member Author

@sorenlouv sorenlouv Jul 30, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of referring to a type I refer to a routing function.

@dgieselaar
Copy link
Member

Pretty stoked about this! I've been thinking about this as well, for instance the gap between Joi validation and query parameters feels brittle. I think we could have some kind of DSL that both TypeScript and Joi can handle, that would remove the need for at least some duplication. Another thing that I considered was to have static strings always for an API call, so /api/apm/services/:serviceName/name_of_endpoint would become /api/apm/services/name_of_endpoint with a query parameter serviceName. The callApi method would then no longer need a generic argument, it could just infer it from the url and add validation/autocompletion for parameters passed to it as well:

return await callApi('/api/apm/transaction_groups/distribution', { });

Maybe a little overkill though 😅

@elasticmachine
Copy link
Contributor

💔 Build Failed

@sorenlouv
Copy link
Member Author

sorenlouv commented Jul 31, 2019

for instance the gap between Joi validation and query parameters feels brittle. I think we could have some kind of DSL that both TypeScript and Joi can handle, that would remove the need for at least some duplication

Yes, there is definitely room for improvement. If I added all the things I wanted it would become a sinkhole :D I feel this is a pretty minimal first step, that will allow us to iterate on it. The typescript magic I added is still fairly simple. If we want to go more advanced we might want to look at typesafe-hapi so we don't duplicate that work. But that's a much bigger change if we were to convince Kibana to switch.

@sorenlouv
Copy link
Member Author

Another thing that I considered was to have static strings always for an API call, so /api/apm/services/:serviceName/name_of_endpoint would become /api/apm/services/name_of_endpoint with a query parameter serviceName. The callApi method would then no longer need a generic argument, it could just infer it from the url and add validation/autocompletion for parameters passed to it as well.

That's an interesting thought. I definitely see the benefits of static endpoints. Currently our client side routes and server side APIs map almost 1:1 so we might want to change those as well for consistency.

@sorenlouv sorenlouv force-pushed the improve-backend-to-frontend-contract branch from 72871b0 to 7f7cfbc Compare July 31, 2019 11:53
@sorenlouv sorenlouv self-assigned this Jul 31, 2019
@elasticmachine
Copy link
Contributor

💔 Build Failed

@elasticmachine
Copy link
Contributor

💔 Build Failed

@sorenlouv sorenlouv force-pushed the improve-backend-to-frontend-contract branch from e883295 to 8842233 Compare August 1, 2019 13:02
@elasticmachine
Copy link
Contributor

💔 Build Failed

@elasticmachine
Copy link
Contributor

💔 Build Failed

@sorenlouv sorenlouv changed the title [APM] Improve type safety across application boundaries (POC) [APM] Improve type safety across application boundaries Aug 1, 2019
@elasticmachine
Copy link
Contributor

💔 Build Failed

@elasticmachine
Copy link
Contributor

💚 Build Succeeded

Copy link
Member

@dgieselaar dgieselaar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, just a few nits/questions


export async function getJavaMetricsCharts(setup: Setup, serviceName: string) {
const charts = await Promise.all([
const charts: GenericMetricsChart[] = await Promise.all([
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, curious why we need it

Copy link
Member Author

@sorenlouv sorenlouv Aug 2, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was previously used here:

export interface MetricsChartsByAgentAPIResponse {
charts: GenericMetricsChart[];
}

Removing it caused typescript errors - often this happens when a const like type: 'my-type' is converted to type: string. Re-adding GenericMetricsChart solved it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you find a way to remove it and still keep typescript happy I'll gladly do that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, removing this causes this error:

legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts:14:3 - error TS2322: Type '[]' is not assignable to type '[{ title: string; key: string; yUnit: YUnit; totalHits: number; series: { title: string; key: string; type: ChartType; color: string; overallValue: number | null; data: { x: number; y: number | null; }[]; }[]; }, { ...; }, { ...; }, { ...; }, { ...; }] | [...]'.
  Type '[]' is missing the following properties from type '[{ title: string; key: string; yUnit: YUnit; totalHits: number; series: { title: string; key: string; type: ChartType; color: string; overallValue: number | null; data: { x: number; y: number | null; }[]; }[]; }, { ...; }]': 0, 1

14   charts: []
     ~~~~~~

  legacy/plugins/apm/server/lib/metrics/by_agent/java/index.ts:24:12
    24   return { charts };
                  ~~~~~~
    The expected type comes from property 'charts' which is declared here on type '{ charts: [{ title: string; key: string; yUnit: YUnit; totalHits: number; series: { title: string; key: string; type: ChartType; color: string; overallValue: number | null; data: { x: number; y: number | null; }[]; }[]; }, { ...; }, { ...; }, { ...; }, { ...; }]; } | { ...; }'

legacy/plugins/apm/server/lib/metrics/by_agent/java/index.ts:13:1 - error TS6133: 'GenericMetricsChart' is declared but its value is never read.

13 import { GenericMetricsChart } from '../../transform_metrics_chart';
   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

diff --git a/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts b/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts
index 3480ede7ac..b1b0315b00 100644
--- a/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts
+++ b/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts
@@ -10,8 +10,10 @@ import { useUiFilters } from '../context/UrlParamsContext';
 import { useFetcher } from './useFetcher';
 import { PromiseReturnType } from '../../typings/common';
 
-const INITIAL_DATA: PromiseReturnType<typeof loadMetricsChartData> = {
-  charts: []
+type MetricsChartDataResponse = PromiseReturnType<typeof loadMetricsChartData>;
+
+const INITIAL_DATA = {
+  charts: [] as Array<MetricsChartDataResponse['charts'][0]>
 };
 
 export function useServiceMetricCharts(
diff --git a/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/default.ts b/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/default.ts
index 453ba614f2..f55d3320c7 100644
--- a/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/default.ts
+++ b/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/default.ts
@@ -7,13 +7,12 @@
 import { Setup } from '../../helpers/setup_request';
 import { getCPUChartData } from './shared/cpu';
 import { getMemoryChartData } from './shared/memory';
-import { GenericMetricsChart } from '../transform_metrics_chart';
 
 export async function getDefaultMetricsCharts(
   setup: Setup,
   serviceName: string
 ) {
-  const charts: GenericMetricsChart[] = await Promise.all([
+  const charts = await Promise.all([
     getCPUChartData(setup, serviceName),
     getMemoryChartData(setup, serviceName)
   ]);
diff --git a/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/java/index.ts b/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/java/index.ts
index 7ea1d0bf69..9fc9f3029d 100644
--- a/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/java/index.ts
+++ b/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/java/index.ts
@@ -10,10 +10,9 @@ import { getNonHeapMemoryChart } from './non_heap_memory';
 import { getThreadCountChart } from './thread_count';
 import { getCPUChartData } from '../shared/cpu';
 import { getMemoryChartData } from '../shared/memory';
-import { GenericMetricsChart } from '../../transform_metrics_chart';
 
 export async function getJavaMetricsCharts(setup: Setup, serviceName: string) {
-  const charts: GenericMetricsChart[] = await Promise.all([
+  const charts = await Promise.all([
     getCPUChartData(setup, serviceName),
     getMemoryChartData(setup, serviceName),
     getHeapMemoryChart(setup, serviceName),

});
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we removing this assertion?

Copy link
Member

@dgieselaar dgieselaar Aug 2, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

presumably because of 265cc3b?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly. Removing defaultErrorHandler was perhaps a bit out of the scope for this PR so I can re-add it if you think. Although I don't think it provided us with much (and now the status code is correctly 500 instead of 400).

@elasticmachine
Copy link
Contributor

💚 Build Succeeded

@jasonrhodes
Copy link
Member

Yes, there is definitely room for improvement. If I added all the things I wanted it would become a sinkhole :D I feel this is a pretty minimal first step, that will allow us to iterate on it. The typescript magic I added is still fairly simple. If we want to go more advanced we might want to look at typesafe-hapi so we don't duplicate that work. But that's a much bigger change if we were to convince Kibana to switch.

Hey just for the record, @weltenwort and some of us on infra/logs are evaluating io-ts https://github.com/gcanti/io-ts to try to tackle this problem. See #41836 and #42356 for our first attempts to implement and see if it's worth the DSL. I like what you all have here too, it'd be great if we found a solution that we all used at least across o11y and maybe beyond!

@sorenlouv sorenlouv mentioned this pull request Aug 6, 2019
4 tasks
@elasticmachine
Copy link
Contributor

💚 Build Succeeded

@sorenlouv sorenlouv assigned dgieselaar and unassigned sorenlouv and dgieselaar Aug 14, 2019
@dgieselaar
Copy link
Member

Closing, work was done in #42961.

@dgieselaar dgieselaar closed this Aug 24, 2019
@sorenlouv sorenlouv deleted the improve-backend-to-frontend-contract branch September 25, 2019 12:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
release_note:skip Skip the PR/issue when compiling release notes Team:APM All issues that need APM UI Team support
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants