console: Add OpenTelemetry to Metadata

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/7401
GitOrigin-RevId: 13b768e4a3d940bf2acf8fbc1fe38f89a6c10f05
This commit is contained in:
Stefano Magni 2023-01-23 15:09:47 +01:00 committed by hasura-bot
parent 85cda65261
commit 24cba66344
29 changed files with 780 additions and 81 deletions

View File

@ -104,7 +104,7 @@
"use-async-effect": "2.2.7",
"uuid": "8.3.2",
"xstate": "^4.30.1",
"zod": "3.17.3"
"zod": "3.20.2"
},
"devDependencies": {
"@babel/cli": "7.13.10",
@ -47408,9 +47408,9 @@
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
},
"node_modules/zod": {
"version": "3.17.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.17.3.tgz",
"integrity": "sha512-4oKP5zvG6GGbMlqBkI5FESOAweldEhSOZ6LI6cG+JzUT7ofj1ZOC0PJudpQOpT1iqOFpYYtX5Pw0+o403y4bcg==",
"version": "3.20.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.20.2.tgz",
"integrity": "sha512-1MzNQdAvO+54H+EaK5YpyEy0T+Ejo/7YLHS93G3RnYWh5gaotGHwGeN/ZO687qEDU2y4CdStQYXVHIgrUl5UVQ==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@ -84107,9 +84107,9 @@
}
},
"zod": {
"version": "3.17.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.17.3.tgz",
"integrity": "sha512-4oKP5zvG6GGbMlqBkI5FESOAweldEhSOZ6LI6cG+JzUT7ofj1ZOC0PJudpQOpT1iqOFpYYtX5Pw0+o403y4bcg=="
"version": "3.20.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.20.2.tgz",
"integrity": "sha512-1MzNQdAvO+54H+EaK5YpyEy0T+Ejo/7YLHS93G3RnYWh5gaotGHwGeN/ZO687qEDU2y4CdStQYXVHIgrUl5UVQ=="
},
"zwitch": {
"version": "1.0.5",

View File

@ -174,7 +174,7 @@
"use-async-effect": "2.2.7",
"uuid": "8.3.2",
"xstate": "^4.30.1",
"zod": "3.17.3"
"zod": "3.20.2"
},
"devDependencies": {
"@babel/cli": "7.13.10",

View File

@ -1,6 +1,6 @@
import {
MetadataUtils,
useInvalidateMetata,
useInvalidateMetadata,
useMetadata,
} from '@/features/hasura-metadata-api';
import { MetadataTable } from '@/features/hasura-metadata-types';
@ -16,7 +16,7 @@ export const useUpdateTableConfiguration = (
const { fireNotification } = useFireNotification();
const invalidateMetadata = useInvalidateMetata();
const invalidateMetadata = useInvalidateMetadata();
const { data } = useMetadata(m => ({
source: MetadataUtils.findMetadataSource(dataSourceName, m),

View File

@ -2,7 +2,7 @@ import { useMetadataMigration } from '@/features/MetadataAPI';
import { useFireNotification } from '@/new-components/Notifications';
import React, { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { useInvalidateMetata } from '@/features/hasura-metadata-api';
import { useInvalidateMetadata } from '@/features/hasura-metadata-api';
import { useIsUnmounted } from '@/components/Services/Data';
import { useMetadataSource, tablesQueryKey } from '@/features/Data';
import type { TrackableTable } from '../types';
@ -18,7 +18,7 @@ export const useTrackTable = (dataSourceName: string) => {
const unMounted = useIsUnmounted();
const { data } = useMetadataSource(dataSourceName);
const invalidateMetadata = useInvalidateMetata();
const invalidateMetadata = useInvalidateMetadata();
const metadata = data?.metadata;

View File

@ -1,9 +1,10 @@
import { HasuraMetadataV3 } from '@/metadata/types';
import { MetadataQueryType } from '@/metadata/queryUtils';
import { IntrospectionQuery } from 'graphql';
import { RemoteField } from '../RemoteRelationships/RemoteSchemaRelationships/types';
import type { HasuraMetadataV3 } from '@/metadata/types';
import type { MetadataQueryType } from '@/metadata/queryUtils';
import type { OpenTelemetryQueries } from '@/features/hasura-metadata-types';
import type { IntrospectionQuery } from 'graphql';
import type { RemoteField } from '../RemoteRelationships/RemoteSchemaRelationships/types';
import { DataTarget } from '../Datasources';
import type { DataTarget } from '../Datasources';
export interface MetadataResponse {
resource_version: number;
@ -92,4 +93,5 @@ export type AllMetadataQueries =
export type allowedMetadataTypes =
| typeof allowedMetadataTypesArr[number]
| AllMetadataQueries
| MetadataQueryType;
| MetadataQueryType
| OpenTelemetryQueries;

View File

@ -16,9 +16,6 @@ import { OpenTelemetryConfig } from './OpenTelemetryConfig';
export default {
title: 'Features/OpenTelemetryConfig/OpenTelemetryConfig',
component: OpenTelemetryConfig,
argTypes: {
updateOpenTelemetryConfig: { action: true },
},
} as ComponentMeta<typeof OpenTelemetryConfig>;
// -------------------------------------------------------------------------------------------------
@ -46,8 +43,8 @@ Disabled.storyName = '💠 Disabled';
// a Partial<Props> and then developers cannot know that they break the story by changing the
// component props
const disabledArgs: ComponentPropsWithoutRef<typeof OpenTelemetryConfig> = {
updateOpenTelemetryConfig: (...args) => {
action('updateOpenTelemetryConfig')(...args);
updateOpenTelemetry: (...args) => {
action('updateOpenTelemetry')(...args);
// Fake the server loading
return new Promise(resolve => setTimeout(resolve, 1000));
@ -62,7 +59,7 @@ const disabledArgs: ComponentPropsWithoutRef<typeof OpenTelemetryConfig> = {
batchSize: 512,
attributes: [],
dataType: ['traces'],
connectionType: 'http',
connectionType: 'http/protobuf',
},
};
Disabled.args = disabledArgs;
@ -94,8 +91,8 @@ Enabled.storyName = '💠 Enabled';
// a Partial<Props> and then developers cannot know that they break the story by changing the
// component props
const enabledArgs: ComponentPropsWithoutRef<typeof OpenTelemetryConfig> = {
updateOpenTelemetryConfig: (...args) => {
action('updateOpenTelemetryConfig')(...args);
updateOpenTelemetry: (...args) => {
action('updateOpenTelemetry')(...args);
// Fake the server loading
return new Promise(resolve => setTimeout(resolve, 1000));
@ -110,7 +107,7 @@ const enabledArgs: ComponentPropsWithoutRef<typeof OpenTelemetryConfig> = {
batchSize: 512,
attributes: [],
dataType: ['traces'],
connectionType: 'http',
connectionType: 'http/protobuf',
},
};
Enabled.args = enabledArgs;
@ -142,7 +139,7 @@ Skeleton.storyName = '💠 Skeleton';
// a Partial<Props> and then developers cannot know that they break the story by changing the
// component props
const skeletonArgs: ComponentPropsWithoutRef<typeof OpenTelemetryConfig> = {
updateOpenTelemetryConfig: (...args) => {
updateOpenTelemetry: (...args) => {
action('updateOpenT')(...args);
// Fake the server loading

View File

@ -9,14 +9,14 @@ import { Header } from './components/Header/Header';
interface OpenTelemetryConfigProps {
skeletonMode: boolean;
metadataFormValues: FormValues | undefined;
updateOpenTelemetryConfig: (formValues: FormValues) => Promise<void>;
updateOpenTelemetry: (formValues: FormValues) => Promise<void>;
}
/**
* All the OpenTelemetry page without any external dependency.
*/
export function OpenTelemetryConfig(props: OpenTelemetryConfigProps) {
const { skeletonMode, metadataFormValues, updateOpenTelemetryConfig } = props;
const { skeletonMode, metadataFormValues, updateOpenTelemetry } = props;
const formValues = metadataFormValues || defaultValues;
@ -33,7 +33,7 @@ export function OpenTelemetryConfig(props: OpenTelemetryConfigProps) {
<div>
{/* This div avoid space-y-md separating too much the toggle and the form */}
<Form
onSubmit={updateOpenTelemetryConfig}
onSubmit={updateOpenTelemetry}
defaultValues={formValues}
skeletonMode={skeletonMode}
/>

View File

@ -219,7 +219,7 @@ HappyPath.play = async ({ args, canvasElement }) => {
expect(receivedValues).toMatchObject<FormValues>({
enabled: true,
endpoint: 'http://hasura.io',
connectionType: 'http',
connectionType: 'http/protobuf',
dataType: ['traces'],
batchSize: 100,
headers: [

View File

@ -61,7 +61,7 @@ export function Form(props: FormProps) {
name="connectionType"
label="Connection Type"
tooltip="The protocol to be used for the communication with the receiver. At the moment, only HTTP is supported."
options={[{ value: 'http', label: 'HTTP' }]}
options={[{ value: 'http/protobuf', label: 'HTTP' }]}
loading={skeletonMode}
// At the beginning, only one Connection Type is available, hence it does not make sense
// to enable the users to change it.

View File

@ -1,36 +1,62 @@
import { z } from 'zod';
import { requestHeadersSelectorSchema } from '@/new-components/RequestHeadersSelector';
export const formSchema = z.object({
enabled: z.boolean(),
const endPointSchema = z.string().url({ message: 'Invalid URL' });
endpoint: z.string().url({ message: 'Invalid URL' }),
connectionType: z.enum(['http', 'http2']),
// --------------------------------------------------
// SCHEMA
// --------------------------------------------------
const normalProperties = {
// CONNECTION TYPE
connectionType: z.enum(['http/protobuf']),
dataType: z.enum(['traces']).array(),
// HEADERS
// Names should be validated against /^[a-zA-Z0-9]*$/ but we must be sure the server performs the
// same check. Values should be validated against /^[a-zA-Z0-9_ :;.,\\\/"'\?\!\(\)\{\}\[\]@<>=\-+\*#$&`|~^%]*$/
// see: the CloudFlare docs as an example https://developers.cloudflare.com/rules/transform/request-header-modification/reference/header-format/#:~:text=The%20value%20of%20the%20HTTP,%24%26%60%7C~%5E%25
// More: empty env vars should not be accepted!
headers: requestHeadersSelectorSchema,
// ATM, only string values are accepted out of the wide variety of values OpenTelemetry accepts
// see: https://opentelemetry.io/docs/reference/specification/common/#attribute
// ATTENTION: a restricted version of requestHeadersSelectorSchema should be used here! Because
// the attributes cannot be sent as env vars, even if the same RequestHeadersSelector component is
// used. RequestHeadersSelector accepts a typeSelect prop but the schema does not reflect it!
attributes: requestHeadersSelectorSchema,
// TODO: migrate to coerce
batchSize: z.preprocess(
// see: https://github.com/colinhacks/zod/discussions/330#discussioncomment-4043200
Number,
z
.number()
// The message is the same for min and max to avoid a "The value should be greater than 1"
// error and then another "The value should be lower than 512" one. By using the same message,
// the user will only see one error and understand everything at once.
.min(1, { message: 'The value should be between 1 and 512' })
.max(512, { message: 'The value should be between 1 and 512' })
),
});
batchSize: z.coerce
.number()
// The message is the same for min and max to avoid a "The value should be greater than 1"
// error and then another "The value should be lower than 512" one. By using the same message,
// the user will only see one error and understand everything at once.
.min(1, { message: 'The value should be between 1 and 512' })
.max(512, { message: 'The value should be between 1 and 512' }),
};
export const formSchema = z.discriminatedUnion('enabled', [
z.object({
...normalProperties,
enabled: z.literal(true),
endpoint: endPointSchema,
}),
z.object({
...normalProperties,
enabled: z.literal(false),
endpoint: z.union([
endPointSchema,
// When OpenTelemetry is disabled, the endpoint is not mandatory
z.literal(''),
]),
}),
]);
// --------------------------------------------------
// FORM VALUES
// --------------------------------------------------
export type FormValues = z.infer<typeof formSchema>;
export const defaultValues: FormValues = {
@ -43,7 +69,7 @@ export const defaultValues: FormValues = {
// At the time of writing, the server sets 512 as the default value.
batchSize: 512,
connectionType: 'http',
connectionType: 'http/protobuf',
dataType: ['traces'],

View File

@ -0,0 +1,82 @@
import {
formAttributesToMetadataAttributes,
metadataAttributesToFormAttributes,
} from './metadataAttributesToFormAttributes';
describe('Metadata <--> Form Values', () => {
describe.each`
metadataAttributes | formValues
${[]} | ${[]}
${[{ name: 'foo', value: 'bar' }]} | ${[{ name: 'foo', value: 'bar', type: 'from_value' }]}
${[{ name: 'foo', value: 'bar' }, { name: 'baz', value: 'qux' }]} | ${[{ name: 'foo', value: 'bar', type: 'from_value' }, { name: 'baz', value: 'qux', type: 'from_value' }]}
`(
// TODO: fix the test description variables
'Metadata $metadataAttributes <--> Form Values $formValues conversion',
({ metadataAttributes, formValues }) => {
it('Given the metadata, should return the form values', () => {
expect(metadataAttributesToFormAttributes(metadataAttributes)).toEqual(
formValues
);
});
it('Given the form values, should return the metadata', () => {
expect(formAttributesToMetadataAttributes(formValues)).toEqual(
metadataAttributes
);
});
}
);
describe.each`
metadataAttributes | formValues
${[{ name: 'foo', value: 'bar' }, { name: 'foo', value: 'qux' }]} | ${[{ name: 'foo', value: 'bar', type: 'from_value' }, { name: 'foo', value: 'qux', type: 'from_value' }]}
`(
// TODO: fix the test description variables
'When there are duplicated attributes, should handle them. Given $metadataAttributes and $formValues',
({ metadataAttributes, formValues }) => {
it('Given the metadata, should return the form values', () => {
expect(metadataAttributesToFormAttributes(metadataAttributes)).toEqual(
formValues
);
});
it('Given the form values, should return the metadata', () => {
expect(formAttributesToMetadataAttributes(formValues)).toEqual(
metadataAttributes
);
});
}
);
describe('Nameless attributes in form values', () => {
test.each`
formValues | metadataAttributes
${[{ name: '', value: '', type: 'from_value' }]} | ${[]}
${[{ name: '', value: '', type: 'from_value' }, { name: '', value: '', type: 'from_value' }]} | ${[]}
${[{ name: 'foo', value: 'bar', type: 'from_value' }, { name: '', value: '', type: 'from_value' }]} | ${[{ name: 'foo', value: 'bar' }]}
${[{ name: '', value: '', type: 'from_value' }, { name: 'foo', value: 'bar', type: 'from_value' }]} | ${[{ name: 'foo', value: 'bar' }]}
${[{ name: 'foo', value: 'bar', type: 'from_value' }, { name: '', value: '', type: 'from_value' }, { name: 'baz', value: 'qux', type: 'from_value' }]} | ${[{ name: 'foo', value: 'bar' }, { name: 'baz', value: 'qux' }]}
`(
'when invoked with $formValues, should return $metadataAttributes',
({ metadataAttributes, formValues }) => {
expect(formAttributesToMetadataAttributes(formValues)).toEqual(
metadataAttributes
);
}
);
});
describe('Valueless attributes in form values', () => {
test.each`
formValues | metadataAttributes
${[{ name: 'foo', value: '', type: 'from_value' }]} | ${[{ name: 'foo', value: '' }]}
`(
'when invoked with $formValues, should return $metadataAttributes',
({ metadataAttributes, formValues }) => {
expect(formAttributesToMetadataAttributes(formValues)).toEqual(
metadataAttributes
);
}
);
});
});

View File

@ -0,0 +1,40 @@
import type { OpenTelemetry } from '@/features/hasura-metadata-types';
import type { FormValues } from '../../../OpenTelemetryConfig/components/Form/schema';
type MetadataAttributes = OpenTelemetry['exporter_otlp']['resource_attributes'];
type FormAttributes = FormValues['attributes'];
/**
* Convert the OpenTelemetry attributes into the corresponding form values.
*/
export function metadataAttributesToFormAttributes(
metadataAttributes: MetadataAttributes
) {
return metadataAttributes.reduce<FormAttributes>((acc, metadataAttribute) => {
acc.push({
name: metadataAttribute.name,
value: metadataAttribute.value,
type: 'from_value',
});
return acc;
}, []);
}
/**
* Convert the form attributes into the corresponding metadata values.
*/
export function formAttributesToMetadataAttributes(
formAttributes: FormAttributes
) {
return formAttributes.reduce<MetadataAttributes>((acc, formAttribute) => {
if (formAttribute.name === '') return acc;
acc.push({
name: formAttribute.name,
value: formAttribute.value,
});
return acc;
}, []);
}

View File

@ -0,0 +1,87 @@
import {
formHeadersToMetadataHeaders,
metadataHeadersToFormHeaders,
} from './metadataHeadersToFormHeaders';
describe('Metadata <--> Form Values', () => {
describe.each`
metadataHeaders | formValues
${[]} | ${[]}
${[{ name: 'foo', value: 'bar' }]} | ${[{ name: 'foo', value: 'bar', type: 'from_value' }]}
${[{ name: 'foo', value: 'bar' }, { name: 'baz', value: 'qux' }]} | ${[{ name: 'foo', value: 'bar', type: 'from_value' }, { name: 'baz', value: 'qux', type: 'from_value' }]}
${[{ name: 'foo', value_from_env: 'bar' }]} | ${[{ name: 'foo', value: 'bar', type: 'from_env' }]}
${[{ name: 'foo', value_from_env: 'bar' }, { name: 'baz', value_from_env: 'qux' }]} | ${[{ name: 'foo', value: 'bar', type: 'from_env' }, { name: 'baz', value: 'qux', type: 'from_env' }]}
${[{ name: 'foo', value_from_env: 'bar' }, { name: 'baz', value: 'qux' }]} | ${[{ name: 'foo', value: 'bar', type: 'from_env' }, { name: 'baz', value: 'qux', type: 'from_value' }]}
`(
// TODO: fix the test description variables
'Metadata $metadataHeaders <--> Form Values $formValues conversion',
({ metadataHeaders, formValues }) => {
it('Given the metadata, should return the form values', () => {
expect(metadataHeadersToFormHeaders(metadataHeaders)).toEqual(
formValues
);
});
it('Given the form values, should return the metadata', () => {
expect(formHeadersToMetadataHeaders(formValues)).toEqual(
metadataHeaders
);
});
}
);
describe.each`
metadataHeaders | formValues
${[{ name: 'foo', value: 'bar' }, { name: 'foo', value: 'qux' }]} | ${[{ name: 'foo', value: 'bar', type: 'from_value' }, { name: 'foo', value: 'qux', type: 'from_value' }]}
${[{ name: 'foo', value_from_env: 'bar' }, { name: 'foo', value_from_env: 'qux' }]} | ${[{ name: 'foo', value: 'bar', type: 'from_env' }, { name: 'foo', value: 'qux', type: 'from_env' }]}
${[{ name: 'foo', value_from_env: 'bar' }, { name: 'foo', value: 'qux' }]} | ${[{ name: 'foo', value: 'bar', type: 'from_env' }, { name: 'foo', value: 'qux', type: 'from_value' }]}
`(
// TODO: fix the test description variables
'When there are duplicated headers, should handle them. Given $metadataHeaders and $formValues',
({ metadataHeaders, formValues }) => {
it('Given the metadata, should return the form values', () => {
expect(metadataHeadersToFormHeaders(metadataHeaders)).toEqual(
formValues
);
});
it('Given the form values, should return the metadata', () => {
expect(formHeadersToMetadataHeaders(formValues)).toEqual(
metadataHeaders
);
});
}
);
describe('Nameless headers in form values', () => {
test.each`
formValues | metadataHeaders
${[{ name: '', value: '', type: 'from_value' }]} | ${[]}
${[{ name: '', value: '', type: 'from_value' }, { name: '', value: '', type: 'from_value' }]} | ${[]}
${[{ name: 'foo', value: 'bar', type: 'from_value' }, { name: '', value: '', type: 'from_value' }]} | ${[{ name: 'foo', value: 'bar' }]}
${[{ name: '', value: '', type: 'from_value' }, { name: 'foo', value: 'bar', type: 'from_value' }]} | ${[{ name: 'foo', value: 'bar' }]}
${[{ name: 'foo', value: 'bar', type: 'from_value' }, { name: '', value: '', type: 'from_value' }, { name: 'baz', value: 'qux', type: 'from_value' }]} | ${[{ name: 'foo', value: 'bar' }, { name: 'baz', value: 'qux' }]}
`(
'when invoked with $formValues, should return $metadataHeaders',
({ metadataHeaders, formValues }) => {
expect(formHeadersToMetadataHeaders(formValues)).toEqual(
metadataHeaders
);
}
);
});
describe('Valueless headers in form values', () => {
test.each`
formValues | metadataHeaders
${[{ name: 'foo', value: '', type: 'from_value' }]} | ${[{ name: 'foo', value: '' }]}
`(
'when invoked with $formValues, should return $metadataHeaders',
({ metadataHeaders, formValues }) => {
expect(formHeadersToMetadataHeaders(formValues)).toEqual(
metadataHeaders
);
}
);
});
});

View File

@ -0,0 +1,56 @@
import type { OpenTelemetry } from '@/features/hasura-metadata-types';
import type { FormValues } from '../../../OpenTelemetryConfig/components/Form/schema';
type MetadataHeaders = OpenTelemetry['exporter_otlp']['headers'];
type FormHeaders = FormValues['headers'];
/**
* Convert the OpenTelemetry headers into the corresponding form values.
*/
export function metadataHeadersToFormHeaders(metadataHeaders: MetadataHeaders) {
return metadataHeaders.reduce<FormHeaders>((acc, metadataHeader) => {
// The name cannot be used as a discriminator, but the presence of 'value_from_env' can
if ('value_from_env' in metadataHeader) {
acc.push({
name: metadataHeader.name,
value: metadataHeader.value_from_env,
type: 'from_env',
});
return acc;
}
acc.push({
name: metadataHeader.name,
value: metadataHeader.value,
type: 'from_value',
});
return acc;
}, []);
}
/**
* Convert the form headers into the corresponding metadata values.
*/
export function formHeadersToMetadataHeaders(formHeaders: FormHeaders) {
return formHeaders.reduce<MetadataHeaders>((acc, formHeader) => {
if (formHeader.name === '') return acc;
if (formHeader.type === 'from_env') {
acc.push({
name: formHeader.name,
value_from_env: formHeader.value,
});
return acc;
}
acc.push({
name: formHeader.name,
value: formHeader.value,
});
return acc;
}, []);
}

View File

@ -0,0 +1,51 @@
import type { Metadata } from '@/features/hasura-metadata-types';
import type { FormValues } from '../../OpenTelemetryConfig/components/Form/schema';
import {
formValuesToOpenTelemetryConfig,
openTelemetryConfigToFormValues,
} from './openTelemetryConfigToFormValues';
describe('openTelemetryConfigToFormValues', () => {
const openTelemetryConfig: Metadata['metadata']['opentelemetry'] = {
status: 'disabled',
exporter_otlp: {
resource_attributes: [],
protocol: 'http/protobuf',
headers: [{ name: 'baz', value: 'qux' }],
otlp_traces_endpoint: 'https://hasura.io',
},
data_types: ['traces'],
batch_span_processor: {
max_export_batch_size: 100,
},
};
const formValues: FormValues = {
enabled: false,
batchSize: 100,
attributes: [],
endpoint: 'https://hasura.io',
headers: [{ name: 'baz', value: 'qux', type: 'from_value' }],
// At the beginning, only one Data Type is available
dataType: ['traces'],
// At the beginning, only one Connection Type is available
connectionType: 'http/protobuf',
};
it('When passed with a OpenTelemetryConfig, should return the same values for the form', () => {
expect(openTelemetryConfigToFormValues(openTelemetryConfig)).toEqual(
formValues
);
});
it('When passed with some form values, should return the same values for the OpenTelemetryConfig', () => {
expect(formValuesToOpenTelemetryConfig(formValues)).toEqual(
openTelemetryConfig
);
});
});

View File

@ -0,0 +1,119 @@
import type { Metadata, OpenTelemetry } from '@/features/hasura-metadata-types';
import type { FormValues } from '../../OpenTelemetryConfig/components/Form/schema';
import { defaultValues } from '../../OpenTelemetryConfig/components/Form/schema';
import {
formHeadersToMetadataHeaders,
metadataHeadersToFormHeaders,
} from './metadataToFormConverters/metadataHeadersToFormHeaders';
import {
formAttributesToMetadataAttributes,
metadataAttributesToFormAttributes,
} from './metadataToFormConverters/metadataAttributesToFormAttributes';
/**
* Convert a metadata's OpenTelemetry configuration into its corresponding form values object.
*
* ATTENTION: It takes for granted the OpenTelemetry configuration received from the server respects
* the type! Misalignments, if any, must be caught before calling openTelemetryConfigToFormValues!
*/
export function openTelemetryConfigToFormValues(
openTelemetryConfig: Metadata['metadata']['opentelemetry']
): FormValues {
if (!openTelemetryConfig) return defaultValues;
if (openTelemetryConfig.status === 'disabled') {
return {
enabled: false,
endpoint: openTelemetryConfig.exporter_otlp.otlp_traces_endpoint ?? '',
headers: metadataHeadersToFormHeaders(
openTelemetryConfig.exporter_otlp.headers
),
batchSize: openTelemetryConfig.batch_span_processor.max_export_batch_size,
attributes: metadataAttributesToFormAttributes(
openTelemetryConfig.exporter_otlp.resource_attributes
),
// At the beginning, only one Data Type is available
dataType: ['traces'],
// At the beginning, only one Connection Type is available
connectionType: 'http/protobuf',
};
}
return {
enabled: true,
endpoint: openTelemetryConfig.exporter_otlp.otlp_traces_endpoint,
headers: metadataHeadersToFormHeaders(
openTelemetryConfig.exporter_otlp.headers
),
batchSize: openTelemetryConfig.batch_span_processor.max_export_batch_size,
attributes: metadataAttributesToFormAttributes(
openTelemetryConfig.exporter_otlp.resource_attributes
),
// At the beginning, only one Data Type is available
dataType: ['traces'],
// At the beginning, only one Connection Type is available
connectionType: 'http/protobuf',
};
}
/**
* Convert the form values their corresponding metadata object.
*/
export function formValuesToOpenTelemetryConfig(
formValues: FormValues
): OpenTelemetry {
const otlp_traces_endpoint = formValues.endpoint;
const max_export_batch_size = formValues.batchSize;
// At the beginning, only one Connection Type is available
const protocol = 'http/protobuf';
const headers = formHeadersToMetadataHeaders(formValues.headers);
const resource_attributes = formAttributesToMetadataAttributes(
formValues.attributes
);
if (!formValues.enabled) {
return {
status: 'disabled',
// At the beginning, only one Data Type is available
data_types: ['traces'],
exporter_otlp: {
headers,
protocol,
resource_attributes,
otlp_traces_endpoint,
},
batch_span_processor: {
max_export_batch_size,
},
};
}
return {
status: 'enabled',
// At the beginning, only one Data Type is available
data_types: ['traces'],
exporter_otlp: {
headers,
protocol,
resource_attributes,
otlp_traces_endpoint,
},
batch_span_processor: {
max_export_batch_size,
},
};
}

View File

@ -0,0 +1 @@
export * from './metadata.mock';

View File

@ -0,0 +1,33 @@
import produce from 'immer';
import type { MetadataReducer } from '@/mocks/actions';
import type { allowedMetadataTypes } from '@/features/MetadataAPI';
import type {
Metadata,
SetOpenTelemetryConfigQuery,
} from '@/features/hasura-metadata-types';
import { parseOpenTelemetry } from '@/features/hasura-metadata-types';
export const openTelemetryConfigInitialData: Partial<Metadata['metadata']> = {
opentelemetry: undefined,
};
export const metadataHandlers: Partial<
Record<allowedMetadataTypes, MetadataReducer>
> = {
// ATTENTION: the server errors that the Console prevents are not handled here
set_opentelemetry_config: (state, action) => {
// TODO: strongly type it
const newOpenTelemetryConfig =
action.args as SetOpenTelemetryConfigQuery['args'];
const result = parseOpenTelemetry(newOpenTelemetryConfig);
if (!result.success) {
throw result.error;
}
return produce(state, draft => {
draft.metadata.opentelemetry = newOpenTelemetryConfig;
});
},
};

View File

@ -1,7 +1,7 @@
import * as MetadataSelectors from './selectors';
import * as MetadataUtils from './utils';
export { useMetadata, useInvalidateMetata } from './useMetadata';
export { useMetadata, useInvalidateMetadata } from './useMetadata';
export { areTablesEqual } from './areTablesEqual';
export { MetadataSelectors };
export { MetadataUtils };

View File

@ -6,7 +6,7 @@ import { useQuery, useQueryClient } from 'react-query';
export const DEFAULT_STALE_TIME = 5 * 60000; // 5 minutes as default stale time
/*
/*
See the ./metadata-hooks for examples of how to use this hook
Use the selector arg to tell react-query which part(s) of the metadata you want
Default stale time is 5 minutes, but can be adjusted using the staleTime arg
@ -14,7 +14,7 @@ export const DEFAULT_STALE_TIME = 5 * 60000; // 5 minutes as default stale time
const METADATA_QUERY_KEY = 'export_metadata';
export const useInvalidateMetata = () => {
export const useInvalidateMetadata = () => {
const queryClient = useQueryClient();
const invalidate = useCallback(
() => queryClient.invalidateQueries([METADATA_QUERY_KEY]),
@ -29,7 +29,7 @@ export const useMetadata = <T = Metadata>(
staleTime: number = DEFAULT_STALE_TIME
) => {
const httpClient = useHttpClient();
const invalidateMetadata = useInvalidateMetata();
const invalidateMetadata = useInvalidateMetadata();
const queryReturn = useQuery({
queryKey: [METADATA_QUERY_KEY],

View File

@ -12,3 +12,4 @@ export * from './apiLimits';
export * from './graphqlSchemaIntrospection';
export * from './permissions';
export * from './metadata';
export * from './openTelemetry';

View File

@ -1,15 +1,16 @@
import { InheritedRole } from './inheritedRoles';
import { AllowList } from './allowList';
import { QueryCollection } from './queryCollections';
import { Source } from './source';
import { BackendConfigs } from './backendConfigs';
import { RemoteSchema } from './remoteSchemas';
import { Action, CustomTypes } from './actions';
import { CronTrigger } from './cronTriggers';
import { Network } from './network';
import { RestEndpoint } from './restEndpoints';
import { ApiLimits } from './apiLimits';
import { GraphQLSchemaIntrospection } from './graphqlSchemaIntrospection';
import type { InheritedRole } from './inheritedRoles';
import type { AllowList } from './allowList';
import type { QueryCollection } from './queryCollections';
import type { Source } from './source';
import type { BackendConfigs } from './backendConfigs';
import type { RemoteSchema } from './remoteSchemas';
import type { Action, CustomTypes } from './actions';
import type { CronTrigger } from './cronTriggers';
import type { Network } from './network';
import type { RestEndpoint } from './restEndpoints';
import type { ApiLimits } from './apiLimits';
import type { GraphQLSchemaIntrospection } from './graphqlSchemaIntrospection';
import type { OpenTelemetry } from './openTelemetry';
export type Metadata = {
resource_version: number;
@ -28,5 +29,13 @@ export type Metadata = {
rest_endpoints?: RestEndpoint[];
api_limits?: ApiLimits;
graphql_schema_introspection?: GraphQLSchemaIntrospection;
/**
* The EE Lite OpenTelemetry settings.
*
* ATTENTION: Both Lux and the EE Lite server allow configuring OpenTelemetry. Anyway, this only
* represents the EE Lite one since Lux stores the OpenTelemetry settings by itself.
*/
opentelemetry?: OpenTelemetry;
};
};

View File

@ -0,0 +1,3 @@
export * from './types';
export * from './queries';
export * from './parsers';

View File

@ -0,0 +1,16 @@
import { openTelemetrySchema } from '.';
/**
* Allow to parse the data the server returns and early catch possible console/server misalignments.
*
* Please note that this is not a strict check (https://github.com/colinhacks/zod#strict) since
* strict on discriminated unions is not supported by Zod. Hence if the server adds more properties
* to OpenTelemetry and the Console is not updated, chances are the Console strips out the extra
* properties.
*
* More: this function does not throw in case of incompatible types. It's up to the consumer to
* decide what to do in case of mismatch.
*/
export function parseOpenTelemetry(object: unknown) {
return openTelemetrySchema.safeParse(object);
}

View File

@ -0,0 +1,40 @@
import type { OpenTelemetry } from './types';
export type OpenTelemetryQueries = SetOpenTelemetryConfigQuery['type'];
// --------------------------------------------------
// SET OPENTELEMETRY CONFIG
// --------------------------------------------------
/**
* Allow to set/update the OpenTelemetry configuration.
*/
export type SetOpenTelemetryConfigQuery = {
type: 'set_opentelemetry_config';
args: OpenTelemetry;
successResponse: { message: 'success' };
errorResponse: SetOpenTelemetryConfigQueryErrors;
};
// ERRORS
// Please note that this is a non-comprehensive list of errors. It does not include
// - all the server-prevented errors also prevented by the Console: ex. 'Invalid URL' and
// 'max_export_batch_size must be a positive integer'
// - passing a wrong OpenTelemetry config, prevented by the Console
// that would result in something like {"code":"unexpected","error":"cannot continue due to new inconsistent metadata","internal":[{"definition":{"headers":[],"protocol":"http/protobuf","resource_attributes":[]},"name":"open_telemetry exporter_otlp","reason":"Inconsistent object: Missing traces endpoint","type":"open_telemetry"}],"path":"$.args"}%
// - all the internal server errors
// ATTENTION: the `unknown` type is useful to force the consumer to manage every kind of possible
// error object. Unfortunately, we do not have good visibility over the server errors yet, and
// treating them as a black box (apart from some particular cases) is the only thing we can do.
type SetOpenTelemetryConfigQueryErrors = ErrorsNotManagedByTheConsole | unknown;
type ErrorsNotManagedByTheConsole = {
httpStatus: 400;
} & {
code: 'parse-failed';
error: `Environment variable not found: "${string}"`;
path: '$.args';
};

View File

@ -0,0 +1,119 @@
import { z } from 'zod';
// --------------------------------------------------
// UTILS
// --------------------------------------------------
const validUrlSchema = z.string().url({ message: 'Invalid URL' });
// --------------------------------------------------
// ATTRIBUTES
// --------------------------------------------------
const attributeSchema = z.object({
name: z.string(),
// ATM, only string values are accepted out of the wide variety of values OpenTelemetry accepts
// see: https://opentelemetry.io/docs/reference/specification/common/#attribute
value: z.string(),
});
// --------------------------------------------------
// HEADERS
// --------------------------------------------------
const userDefinedHeaderSchema = z.object({
// Names should be validated against /^[a-zA-Z0-9]*$/ but we must be sure the server performs the
// same check.
// see: the CloudFlare docs as an example https://developers.cloudflare.com/rules/transform/request-header-modification/reference/header-format/#:~:text=The%20value%20of%20the%20HTTP,%24%26%60%7C~%5E%25
name: z.string(),
// Values should be validated against /^[a-zA-Z0-9_ :;.,\\\/"'\?\!\(\)\{\}\[\]@<>=\-+\*#$&`|~^%]*$/
// but we must be sure the server performs the same check.
// see: the CloudFlare docs as an example https://developers.cloudflare.com/rules/transform/request-header-modification/reference/header-format/#:~:text=The%20value%20of%20the%20HTTP,%24%26%60%7C~%5E%25
value: z.string(),
});
const envVarHeaderSchema = z.object({
// Names should be validated against /^[a-zA-Z0-9]*$/ but we must be sure the server performs the
// same check.
// see: the CloudFlare docs as an example https://developers.cloudflare.com/rules/transform/request-header-modification/reference/header-format/#:~:text=The%20value%20of%20the%20HTTP,%24%26%60%7C~%5E%25
name: z.string(),
// FYI: The env vars do exist in the server! The server prevents setting unexisting env vars
// Values should be validated against /^[a-zA-Z0-9_ :;.,\\\/"'\?\!\(\)\{\}\[\]@<>=\-+\*#$&`|~^%]*$/
// but we must be sure the server performs the same check.
// see: the CloudFlare docs as an example https://developers.cloudflare.com/rules/transform/request-header-modification/reference/header-format/#:~:text=The%20value%20of%20the%20HTTP,%24%26%60%7C~%5E%25
value_from_env: z.string(),
});
const headersSchema = z.array(
z.union([userDefinedHeaderSchema, envVarHeaderSchema])
);
// --------------------------------------------------
// OTHER PROPERTIES
// --------------------------------------------------
// In the future, also 'metrics' | 'logs' will be available
const dataTypesSchema = z.literal('traces');
// In the future, also 'grpc' will be available
const protocolSchema = z.literal('http/protobuf');
// --------------------------------------------------
// EXPORTER
// --------------------------------------------------
const exporterSchema = z.object({
headers: headersSchema,
protocol: protocolSchema,
resource_attributes: z.array(attributeSchema),
/**
* The most important part of the configuration. You cannot enable OpenTelemetry without a valid
* endpoint.
*/
otlp_traces_endpoint: validUrlSchema,
});
// --------------------------------------------------
// ENABLED/DISABLED DIFFERENCES
// --------------------------------------------------
const enabledOpenTelemetrySchema = {
/**
* If OpenTelemetry is enabled or not. Allows to enable/disable the feature without losing the
* configuration and/or inferring the status from other data (initially, data_types meant that
* OpenTelemetry is disabled if it is empty but this is not true anymore because of the bad UX
* consequences).
*/
status: z.literal('enabled'),
/**
* The request headers sent to the OpenTelemetry endpoint.
*/
data_types: z.array(dataTypesSchema),
batch_span_processor: z.object({
// a value between 1 and 512
max_export_batch_size: z.number().min(1).max(512),
}),
exporter_otlp: exporterSchema,
};
const disabledOpenTelemetrySchema = {
...enabledOpenTelemetrySchema,
status: z.literal('disabled'),
exporter_otlp: exporterSchema.partial({
// If OpenTelemetry is disabled, the endpoint is not required
otlp_traces_endpoint: true,
}),
};
// --------------------------------------------------
// OPEN TELEMETRY
// --------------------------------------------------
export const openTelemetrySchema = z.discriminatedUnion('status', [
z.object(disabledOpenTelemetrySchema),
z.object(enabledOpenTelemetrySchema),
]);
export type OpenTelemetry = z.infer<typeof openTelemetrySchema>;

View File

@ -1,8 +1,11 @@
import { Moment } from 'moment';
import { KeyValuePair } from '@/components/Common/ConfigureTransformation/stateDefaults';
import { Nullable } from './../components/Common/utils/tsUtils';
import { Driver } from '../dataSources';
import { PermissionsType } from '../components/Services/RemoteSchema/Permissions/types';
import type { Moment } from 'moment';
import type { OpenTelemetry } from '@/features/hasura-metadata-types';
import type { KeyValuePair } from '@/components/Common/ConfigureTransformation/stateDefaults';
import type { Driver } from '../dataSources';
import type { Nullable } from './../components/Common/utils/tsUtils';
import type { PermissionsType } from '../components/Services/RemoteSchema/Permissions/types';
export type DataSource = {
name: string;
@ -1232,6 +1235,14 @@ export interface HasuraMetadataV3 {
graphql_schema_introspection?: {
disabled_for_roles: string[];
};
/**
* The EE Lite OpenTelemetry settings.
*
* ATTENTION: Both Lux and the EE Lite server allow configuring OpenTelemetry. Anyway, this only
* represents the EE Lite one since Lux stores the OpenTelemetry settings by itself.
*/
opentelemetry?: OpenTelemetry;
}
// Inconsistent Objects

View File

@ -1,9 +1,10 @@
import type { Metadata } from '@/features/hasura-metadata-types';
import { allowedMetadataTypes } from '@/features/MetadataAPI';
import { Metadata } from '@/features/hasura-metadata-types';
import { metadataHandlers as allowListMetadataHandlers } from '@/features/AllowLists';
import { metadataHandlers as queryCollectionMetadataHandlers } from '@/features/QueryCollections';
import { metadataHandlers as adhocEventMetadataHandlers } from '@/features/AdhocEvents';
import { metadataHandlers as queryCollectionMetadataHandlers } from '@/features/QueryCollections';
import { metadataHandlers as openTelemetryMetadataHandlers } from '@/features/OpenTelemetryConfig';
import { TMigration } from '../features/MetadataAPI/hooks/useMetadataMigration';
@ -30,6 +31,7 @@ const metadataHandlers: Partial<Record<allowedMetadataTypes, MetadataReducer>> =
...allowListMetadataHandlers,
...queryCollectionMetadataHandlers,
...adhocEventMetadataHandlers,
...openTelemetryMetadataHandlers,
};
export const metadataReducer: MetadataReducer = (state, action) => {

View File

@ -1,9 +1,11 @@
import type { ServerConfig } from '@/hooks';
import type { TMigration } from '@/features/MetadataAPI';
import type { Metadata } from '@/features/hasura-metadata-types';
import { allowListInitialData } from '@/features/AllowLists';
import { queryCollectionInitialData } from '@/features/QueryCollections';
import { TMigration } from '@/features/MetadataAPI';
import { Metadata } from '@/features/hasura-metadata-types';
import { openTelemetryConfigInitialData } from '@/features/OpenTelemetryConfig';
import { ServerConfig } from '@/hooks';
import { rest } from 'msw';
import { metadataReducer } from './actions';
@ -15,6 +17,7 @@ export const createDefaultInitialData = (): Metadata => ({
inherited_roles: [],
...allowListInitialData,
...queryCollectionInitialData,
...openTelemetryConfigInitialData,
},
});
@ -31,9 +34,9 @@ type HandlersOptions = {
const defaultOptions: HandlersOptions = {
delay: 0,
initialData: createDefaultInitialData,
config: defaultConfig,
url: 'http://localhost:8080',
initialData: createDefaultInitialData,
};
export const handlers = (options?: HandlersOptions) => {
@ -49,8 +52,9 @@ export const handlers = (options?: HandlersOptions) => {
rest.get(`${url}/v1alpha1/config`, (req, res, ctx) => {
return res(ctx.delay(delay), ctx.status(200), ctx.json(config));
}),
rest.post(`${url}/v1/metadata`, (req, res, ctx) => {
const body = req.body as TMigration['query'];
rest.post(`${url}/v1/metadata`, async (req, res, ctx) => {
const body = (await req.json()) as TMigration['query'];
const response = metadataReducer(metadata, body);