console: Add the OpenTelemetry settings

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/7665
GitOrigin-RevId: 9d150ec5137f8c2f8d1f0a921aa4a686720070a1
This commit is contained in:
Stefano Magni 2023-01-27 01:44:21 +01:00 committed by hasura-bot
parent c6b28b56d1
commit f5ea2e6b5a
74 changed files with 1764 additions and 277 deletions

View File

@ -0,0 +1,18 @@
// import type { ServerConfig } from '../../../../../../src/hooks/useServerConfig';
export const config /*: ServerConfig*/ = {
version: 'v2.8.1',
is_function_permissions_inferred: true,
default_naming_convention: '',
is_remote_schema_permissions_enabled: true,
is_admin_secret_set: false,
is_auth_hook_set: false,
is_jwt_set: false,
is_allow_list_enabled: false,
experimental_features: [],
jwt: {
claims_namespace: '',
claims_format: '',
},
is_prometheus_metrics_enabled: false,
};

View File

@ -0,0 +1,9 @@
// import type { MetadataResponse } from '../../../../../../src/features/MetadataAPI/types';
export const export_metadata /*: MetadataResponse*/ = {
resource_version: 0,
metadata: {
version: 3,
sources: [],
},
};

View File

@ -0,0 +1,17 @@
/**
* At the time of writing, the get_catalog_state request is not typed at all in the Console.
*/
export const get_catalog_state = {
id: 'b23fe106-4c3e-405f-9ca5-4aef77dec759',
cli_state: {
settings: { migration_mode: 'true' },
isStateCopyCompleted: true,
},
console_state: {
telemetryNotificationShown: true,
onboardingShown: true,
console_notifications: {
admin: { read: [], showBadge: true, date: null },
},
},
};

View File

@ -0,0 +1,7 @@
/**
* At the time of writing, the get_inconsistent_metadata request is not typed at all in the Console.
*/
export const get_inconsistent_metadata = {
is_consistent: true,
inconsistent_objects: [],
};

View File

@ -0,0 +1,41 @@
import { config } from './config';
import { version } from './version';
import { export_metadata } from './export_metadata';
import { get_catalog_state } from './get_catalog_state';
import { get_inconsistent_metadata } from './get_inconsistent_metadata';
export function stubInitialServerRequests() {
cy.log('**--- Stub all the initial requests**');
cy.intercept('http://localhost:8080/v1/metadata', req => {
if (req.body.type === 'export_metadata') {
req.alias = 'export_metadata';
req.reply(export_metadata);
}
if (req.body.type === 'get_inconsistent_metadata') {
req.alias = 'get_inconsistent_metadata';
req.reply(get_inconsistent_metadata);
}
if (req.body.type === 'get_catalog_state') {
req.alias = 'get_catalog_state';
req.reply(get_catalog_state);
}
});
cy.intercept('http://localhost:8080/v1alpha1/config', { body: config }).as(
'config'
);
cy.intercept('http://localhost:8080/v1/version', { body: version }).as(
'version'
);
}
export function waitForInitialServerRequests() {
// In no particular order...
cy.wait('@config');
cy.wait('@version');
cy.wait('@export_metadata');
cy.wait('@get_catalog_state');
cy.wait('@get_inconsistent_metadata');
}

View File

@ -0,0 +1,6 @@
/**
* At the time of writing, the version request is not typed at all in the Console.
*/
export const version = {
version: 'dev-d9ed85c-alexopentelemetry-always-validate',
};

View File

@ -0,0 +1,45 @@
import { testMode } from '../../../helpers/common';
import { logMetadataRequests } from './utils/requests/logMetadataRequests';
import { openTelemetryMustNotExist } from './utils/testState/openTelemetryMustNotExist';
// NOTE: This test suite does not include cases for relationships, headers and the codegen part
if (testMode !== 'cli') {
// Why this test is skipped?
// - because OpenTelemetry is a "Pro Console" feature (it works with a EE Lite server)
// - at the moment, we do not have E2E tests for the Pro Console
// - anyway, from a server standpoint, an EE Lite server is available in CI and usable for the E2E tests
// but these tests are fundamental to locally test the OpenTelemetry feature and being sure it works
// properly
describe.skip('OpenTelemetry', () => {
beforeEach(() => {
openTelemetryMustNotExist();
logMetadataRequests();
cy.visit('/settings/opentelemetry');
});
afterEach(() => {
openTelemetryMustNotExist();
});
it('When OpenTelemetry is set up, then everything should work', () => {
cy.log('**--- STEP: Enable OpenTelemetry**');
cy.findByLabelText('Status').click();
cy.log('**--- STEP: Type the Endpoint**');
cy.findByLabelText('Endpoint', { selector: 'input' }).type(
'http://example.io'
);
cy.log('**--- STEP: Click the Submit button**');
cy.findByRole('button', { name: 'Connect' }).click();
cy.log('**--- STEP: Check the success notification**');
cy.expectSuccessNotificationWithMessage(
'Successfully updated the OpenTelemetry Configuration'
);
});
});
}

View File

@ -0,0 +1,208 @@
import { z } from 'zod';
import produce from 'immer';
import { blockServerRequests } from './utils/requests/blockServerRequests';
import {
stubInitialServerRequests,
waitForInitialServerRequests,
} from './fixtures/initialRequests/stubInitialServerRequests';
import type {
unexistingEnvVarSchema,
SetOpenTelemetryQuery,
hasuraEnvVarsNotAllowedSchema,
} from '../../../../src/features/hasura-metadata-types';
import { export_metadata } from './fixtures/initialRequests/export_metadata';
// Why this test is skipped?
// - because OpenTelemetry is a "Pro Console" feature (it works with a EE Lite server)
// - at the moment, we do not have E2E tests for the Pro Console
// but these tests are fundamental to locally test the OpenTelemetry feature and being sure it works
// properly
describe.skip('OpenTelemetry', () => {
beforeEach(() => {
cy.log('**--- Start controlling the server**');
blockServerRequests();
stubInitialServerRequests();
cy.log('**--- Load the Console**');
cy.visit('/settings/opentelemetry');
waitForInitialServerRequests();
});
it('When OpenTelemetry is set up, then everything should work', () => {
cy.log('**--- STEP: Enable OpenTelemetry**');
cy.findByLabelText('Status').click();
cy.log('**--- STEP: Type the Endpoint**');
cy.findByLabelText('Endpoint', { selector: 'input' }).type(
'http://example.io'
);
cy.log(
'**--- STEP: Intercept the set_opentelemetry_config request and the next export_metadata one**'
);
let openTelemetryFixture: SetOpenTelemetryQuery['args'] | undefined;
cy.intercept('POST', 'http://localhost:8080/v1/metadata', req => {
if (req.body.type === 'set_opentelemetry_config') {
Cypress.log({
message: '**--- STEP: Intercept the set_opentelemetry_config call**',
});
req.alias = 'set_opentelemetry_config';
// Steal the server openTelemetry configuration from the one passed by the Console
openTelemetryFixture = req.body.args;
const fixture: SetOpenTelemetryQuery['successResponse'] = {
message: 'success',
};
req.reply(fixture);
}
if (req.body.type === 'export_metadata') {
Cypress.log({
message: '**--- STEP: Intercept the export_metadata call**',
});
req.alias = 'export_metadata_with_opentelemetry';
req.reply(
// Use the openTelemetry configuration passed from the Console to the server to get back
// a metadata that includes the same configuration
produce(export_metadata, draft => {
draft.metadata.opentelemetry = openTelemetryFixture;
})
);
}
});
cy.log('**--- STEP: Click the Submit button**');
// Why update and not connect?
cy.findByRole('button', { name: 'Connect' }).click();
cy.log('**--- STEP: Wait for the Console to save OpenTelemetry**');
cy.wait('@set_opentelemetry_config');
cy.log('**--- STEP: Wait for the Console to refetch metadata**');
cy.wait('@export_metadata_with_opentelemetry');
cy.log('**--- STEP: Check the success notification**');
cy.expectSuccessNotificationWithMessage(
'Successfully updated the OpenTelemetry Configuration'
);
});
it('When an unexisting env var is added, then the user should be prompted about it', () => {
cy.log(
'**--- STEP: Intercept the set_opentelemetry_config request and return the error**'
);
cy.intercept('POST', 'http://localhost:8080/v1/metadata', req => {
if (req.body.type === 'set_opentelemetry_config') {
Cypress.log({
message: '**--- STEP: Intercept the set_opentelemetry_config call**',
});
req.alias = 'set_opentelemetry_config';
const fixture: z.infer<typeof unexistingEnvVarSchema> = {
// That's the only part of thr error the Console cares about
internal: [
{
reason: `Inconsistent object: environment variable 'foo' not set`,
},
],
};
req.reply({
statusCode: 400,
body: fixture,
});
}
});
cy.log('**--- STEP: Click the Submit button**');
cy.findByRole('button', { name: 'Connect' }).click();
cy.log('**--- STEP: Wait for the Console to save OpenTelemetry**');
cy.wait('@set_opentelemetry_config');
cy.log('**--- STEP: Check the error notification**');
cy.expectErrorNotificationWithMessage(
`Inconsistent object: environment variable 'foo' not set`
);
});
it('When an HASURA_GRAPHQL_ env var is added, then the user should be prompted about it', () => {
cy.log(
'**--- STEP: Intercept the set_opentelemetry_config request and return the error**'
);
cy.intercept('POST', 'http://localhost:8080/v1/metadata', req => {
if (req.body.type === 'set_opentelemetry_config') {
Cypress.log({
message: '**--- STEP: Intercept the set_opentelemetry_config call**',
});
req.alias = 'set_opentelemetry_config';
const fixture: z.infer<typeof hasuraEnvVarsNotAllowedSchema> = {
// That's the only part of thr error the Console cares about
error:
'env variables starting with "HASURA_GRAPHQL_" are not allowed in value_from_env: HASURA_GRAPHQL_ENABLED_APIS',
};
req.reply({
statusCode: 400,
body: fixture,
});
}
});
cy.log('**--- STEP: Click the Submit button**');
cy.findByRole('button', { name: 'Connect' }).click();
cy.log('**--- STEP: Wait for the Console to save OpenTelemetry**');
cy.wait('@set_opentelemetry_config');
cy.log('**--- STEP: Check the error notification**');
cy.expectErrorNotificationWithMessage(
`env variables starting with "HASURA_GRAPHQL_" are not allowed in value_from_env: HASURA_GRAPHQL_ENABLED_APIS`
);
});
it('When an unexpected error is returned from the server, then the user should be prompted about it', () => {
cy.log(
'**--- STEP: Intercept the set_opentelemetry_config request and return the error**'
);
cy.intercept('POST', 'http://localhost:8080/v1/metadata', req => {
if (req.body.type === 'set_opentelemetry_config') {
Cypress.log({
message: '**--- STEP: Intercept the set_opentelemetry_config call**',
});
req.alias = 'set_opentelemetry_config';
const fixture = {
unmanagedError: 'An error the Console does not manage',
};
req.reply({
statusCode: 400,
body: fixture,
});
}
});
cy.log('**--- STEP: Click the Submit button**');
cy.findByRole('button', { name: 'Connect' }).click();
cy.log('**--- STEP: Wait for the Console to save OpenTelemetry**');
cy.wait('@set_opentelemetry_config');
cy.log('**--- STEP: Check the error notification**');
cy.expectErrorNotificationWithMessage(
`{"unmanagedError":"An error the Console does not manage"}`
);
});
});

View File

@ -0,0 +1,3 @@
module.exports = {
"__version": "10.4.0"
}

View File

@ -0,0 +1,9 @@
/**
* Ensure no requests hit the server, it's mostly a safe protection for the developers running
* server-free tests that could miss some requests are actually hitting the real server, then
* resulting in test flakiness when because of partial server stubbing.
*/
export function blockServerRequests() {
cy.log('**--- Prevent any requests to hit the real server**');
cy.intercept('http://localhost:8080/**', { forceNetworkError: true });
}

View File

@ -0,0 +1,36 @@
interface SingleMetadataRequest {
type: string;
// There are a lot of other fields, but tracking them is not important for the purpose of this module
}
interface BulkMetadataRequest {
type: 'bulk';
args: SingleMetadataRequest[];
}
type MetadataRequest = SingleMetadataRequest | BulkMetadataRequest;
/*
* Log all the requests outgoing to the Metadata endpoint.
* This is useful to have a glance of the requests that are going to the server.
*/
export function logMetadataRequests() {
cy.intercept('POST', 'http://localhost:8080/v1/metadata', req => {
const noArgs = !req.body.args;
if (noArgs) return;
const requestBody = req.body as MetadataRequest;
if (requestBody.type === 'bulk' || requestBody.type === 'concurrent_bulk') {
const request = requestBody as BulkMetadataRequest;
Cypress.log({ message: '*--- Bulk request*' });
request.args.forEach(arg =>
Cypress.log({ message: `*--- Request: ${arg.type}*` })
);
} else {
Cypress.log({ message: `*--- Request: ${requestBody.type}*` });
}
});
}

View File

@ -0,0 +1,13 @@
/**
* Delete OpenTelemetry straight on the server.
*/
export function deleteOpenTelemetry() {
Cypress.log({ message: '**--- OpenTelemetry delete: start**' });
return cy
.request('POST', 'http://localhost:8080/v1/metadata', {
type: 'set_opentelemetry_config',
args: {},
})
.then(() => Cypress.log({ message: '**--- OpenTelemetry delete: end**' }));
}

View File

@ -0,0 +1,15 @@
/**
* Read the Metadata straight from the server.
*/
export function readMetadata() {
Cypress.log({ message: '**--- Metadata read: start**' });
return cy
.request('POST', 'http://localhost:8080/v1/metadata', {
args: {},
type: 'export_metadata',
})
.then(_response => {
Cypress.log({ message: '**--- Metadata read: end**' });
});
}

View File

@ -0,0 +1,18 @@
import { readMetadata } from '../services/readMetadata';
import { deleteOpenTelemetry } from '../services/deleteOpenTelemetry';
/**
* Ensure the OpenTelemetry configuration does not exist.
*/
export function openTelemetryMustNotExist() {
Cypress.log({ message: '**--- OpenTelemetry check: start**' });
readMetadata().then(response => {
const openTelemetryExists = !!response.body.opentelemetry;
if (openTelemetryExists) {
Cypress.log({ message: '**--- OpenTelemetry must be deleted**' });
deleteOpenTelemetry();
}
});
}

View File

@ -85,6 +85,8 @@ export { ReactQueryProvider, reactQueryClient } from '../lib/lib/reactQuery';
export { PrometheusSettings } from '../lib/features/Prometheus';
export { OpenTelemetryFeature } from '../lib/features/OpenTelemetry';
export { FeatureFlags } from '../lib/features/FeatureFlags';
export {

View File

@ -48,6 +48,9 @@ class ErrorBoundary extends React.Component<
override componentDidCatch(error: Error) {
const { dispatch } = this.props;
// ATTENTION: No need to setup anything for Sentry here, Sentry automatically tracks the error
// caught from the error boundaries!
// for invalid path segment errors
if (error instanceof NotFoundError) {
this.setState({

View File

@ -2,6 +2,7 @@
import React from 'react';
import { Link, RouteComponentProps } from 'react-router';
import { useServerConfig } from '@/hooks';
import { useMetadata } from '@/features/MetadataAPI';
import LeftContainer from '../../Common/Layout/LeftContainer/LeftContainer';
import CheckIcon from '../../Common/Icons/Check';
import CrossIcon from '../../Common/Icons/Cross';
@ -37,6 +38,7 @@ type SectionDataKey =
| 'inherited-roles'
| 'insecure-domain'
| 'prometheus-settings'
| 'opentelemetry-settings'
| 'feature-flags';
interface SectionData {
@ -152,6 +154,11 @@ const Sidebar: React.FC<SidebarProps> = ({ location, metadata }) => {
});
}
const openTelemetryButton = useOpenTelemetryButton();
if (isProLiteConsole(window.__env)) {
sectionsData.push(openTelemetryButton);
}
sectionsData.push({
key: 'feature-flags',
link: '/settings/feature-flags',
@ -187,3 +194,31 @@ const Sidebar: React.FC<SidebarProps> = ({ location, metadata }) => {
};
export default Sidebar;
function useOpenTelemetryButton(): SectionData {
const { data: openTelemetry } = useMetadata(m => m.metadata.opentelemetry);
const openTelemetryStatus = !openTelemetry
? 'unknown'
: openTelemetry.status === 'enabled'
? 'enabled'
: 'disabled';
const title = (
<span>
OpenTelemetry Exporter{' '}
{openTelemetryStatus === 'enabled' ? (
<CheckIcon className="ml-sm" />
) : openTelemetryStatus === 'disabled' ? (
<TimesCircleIcon className="ml-sm mb-1 h-5 w-5" />
) : null}
</span>
);
return {
key: 'opentelemetry-settings',
link: '/settings/opentelemetry',
dataTestVal: 'opentelemetry-settings-link',
title,
};
}

View File

@ -36,7 +36,7 @@ interface CustomEventOptions {
}
/**
* Track a custom event both in Heap and Sentry.
* Track a custom event in Heap and breadcrumb in Sentry.
*/
export function trackCustomEvent(
event: HeapEvent,

View File

@ -19,12 +19,18 @@ export type TMigration = {
};
};
type MetadataMigrationOptions = Omit<
UseMutationOptions<Record<string, any>, Error, TMigration>,
'mutationFn'
> & {
errorTransform?: (error: unknown) => unknown;
};
export function useMetadataMigration(
mutationOptions?: Omit<
UseMutationOptions<Record<string, any>, Error, TMigration>,
'mutationFn'
>
metadataMigrationOptions: MetadataMigrationOptions = {}
) {
const { errorTransform, ...mutationOptions } = metadataMigrationOptions;
const { mode } = useConsoleConfig();
// Needed to avoid circular dependency
const headers = useSelector<any>(state => state.tables.dataHeaders) as Record<
@ -32,16 +38,21 @@ export function useMetadataMigration(
string
>;
const queryClient = useQueryClient();
return useMutation(
async props => {
try {
const { query } = props;
const body = query;
const result = await Api.post<RunSQLResponse>({
url: Endpoints.metadata,
headers,
body,
});
const result = await Api.post<RunSQLResponse>(
{
url: Endpoints.metadata,
headers,
body,
},
undefined,
errorTransform
);
return result;
} catch (err) {
@ -51,7 +62,7 @@ export function useMetadataMigration(
{
...mutationOptions,
onSuccess: (data, variables, ctx) => {
/*
/*
During console CLI mode, alert the CLI server to update it's local filesystem after metadata API call is successfull
*/
if (mode === CLI_CONSOLE_MODE) {
@ -68,7 +79,7 @@ export function useMetadataMigration(
});
}
/*
/*
Get the latest metadata from server (this will NOT update metadata that is in the redux state, to do that please pass a custom onSuccess)
*/
queryClient.refetchQueries(['metadata'], { active: true });

View File

@ -2,9 +2,13 @@ import type { ComponentPropsWithoutRef } from 'react';
import type { ComponentStory, ComponentMeta } from '@storybook/react';
import * as React from 'react';
import { expect } from '@storybook/jest';
import { useEffect, useState } from 'react';
import { action } from '@storybook/addon-actions';
import { waitFor, within } from '@storybook/testing-library';
import { OpenTelemetryConfig } from './OpenTelemetryConfig';
import { defaultValues as defaultFormValues } from './components/Form/schema';
import { OpenTelemetry } from './OpenTelemetry';
// --------------------------------------------------
// NOT TESTED
@ -14,9 +18,9 @@ import { OpenTelemetryConfig } from './OpenTelemetryConfig';
// component is tested through the parent component.
export default {
title: 'Features/OpenTelemetryConfig/OpenTelemetryConfig',
component: OpenTelemetryConfig,
} as ComponentMeta<typeof OpenTelemetryConfig>;
title: 'Features/OpenTelemetry/OpenTelemetry',
component: OpenTelemetry,
} as ComponentMeta<typeof OpenTelemetry>;
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
@ -30,8 +34,8 @@ export default {
// --------------------------------------------------
// STORY DEFINITION
// --------------------------------------------------
export const Disabled: ComponentStory<typeof OpenTelemetryConfig> = args => {
return <OpenTelemetryConfig {...args} />;
export const Disabled: ComponentStory<typeof OpenTelemetry> = args => {
return <OpenTelemetry {...args} />;
};
Disabled.storyName = '💠 Disabled';
@ -42,9 +46,11 @@ Disabled.storyName = '💠 Disabled';
// Explicitly defining the story' args allows leveraging TS protection over them since story.args is
// a Partial<Props> and then developers cannot know that they break the story by changing the
// component props
const disabledArgs: ComponentPropsWithoutRef<typeof OpenTelemetryConfig> = {
updateOpenTelemetry: (...args) => {
action('updateOpenTelemetry')(...args);
const disabledArgs: ComponentPropsWithoutRef<typeof OpenTelemetry> = {
isFirstTimeSetup: true,
setOpenTelemetry: (...args) => {
action('setOpenTelemetry')(...args);
// Fake the server loading
return new Promise(resolve => setTimeout(resolve, 1000));
@ -69,7 +75,7 @@ Disabled.args = disabledArgs;
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// DISABLED STORY
// ENABLED STORY
// #region
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
@ -78,8 +84,8 @@ Disabled.args = disabledArgs;
// --------------------------------------------------
// STORY DEFINITION
// --------------------------------------------------
export const Enabled: ComponentStory<typeof OpenTelemetryConfig> = args => {
return <OpenTelemetryConfig {...args} />;
export const Enabled: ComponentStory<typeof OpenTelemetry> = args => {
return <OpenTelemetry {...args} />;
};
Enabled.storyName = '💠 Enabled';
@ -90,9 +96,11 @@ Enabled.storyName = '💠 Enabled';
// Explicitly defining the story' args allows leveraging TS protection over them since story.args is
// a Partial<Props> and then developers cannot know that they break the story by changing the
// component props
const enabledArgs: ComponentPropsWithoutRef<typeof OpenTelemetryConfig> = {
updateOpenTelemetry: (...args) => {
action('updateOpenTelemetry')(...args);
const enabledArgs: ComponentPropsWithoutRef<typeof OpenTelemetry> = {
isFirstTimeSetup: true,
setOpenTelemetry: (...args) => {
action('setOpenTelemetry')(...args);
// Fake the server loading
return new Promise(resolve => setTimeout(resolve, 1000));
@ -126,8 +134,8 @@ Enabled.args = enabledArgs;
// --------------------------------------------------
// STORY DEFINITION
// --------------------------------------------------
export const Skeleton: ComponentStory<typeof OpenTelemetryConfig> = args => {
return <OpenTelemetryConfig {...args} />;
export const Skeleton: ComponentStory<typeof OpenTelemetry> = args => {
return <OpenTelemetry {...args} />;
};
Skeleton.storyName = '💠 Skeleton';
@ -138,8 +146,10 @@ Skeleton.storyName = '💠 Skeleton';
// Explicitly defining the story' args allows leveraging TS protection over them since story.args is
// a Partial<Props> and then developers cannot know that they break the story by changing the
// component props
const skeletonArgs: ComponentPropsWithoutRef<typeof OpenTelemetryConfig> = {
updateOpenTelemetry: (...args) => {
const skeletonArgs: ComponentPropsWithoutRef<typeof OpenTelemetry> = {
isFirstTimeSetup: true,
setOpenTelemetry: (...args) => {
action('updateOpenT')(...args);
// Fake the server loading
@ -147,8 +157,101 @@ const skeletonArgs: ComponentPropsWithoutRef<typeof OpenTelemetryConfig> = {
},
skeletonMode: true,
metadataFormValues: undefined,
metadataFormValues: defaultFormValues,
};
Skeleton.args = skeletonArgs;
// #endregion
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// DEFAULT VALUES TEST
// #region
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// --------------------------------------------------
// STORY DEFINITION
// --------------------------------------------------
const loadingMetadataProps: ComponentPropsWithoutRef<typeof OpenTelemetry> = {
skeletonMode: true,
isFirstTimeSetup: false,
setOpenTelemetry: () => Promise.resolve(),
metadataFormValues: defaultFormValues,
};
const metadataLoadedProps: ComponentPropsWithoutRef<typeof OpenTelemetry> = {
skeletonMode: false,
isFirstTimeSetup: false,
setOpenTelemetry: (...args) => {
action('setOpenTelemetry')(...args);
return Promise.resolve();
},
metadataFormValues: {
// Using non-default values
enabled: true,
batchSize: 99,
dataType: ['traces'],
connectionType: 'http/protobuf',
endpoint: 'http://localhost:1234',
headers: [{ name: 'foo', value: 'bar', type: 'from_value' }],
attributes: [{ name: 'foo', value: 'bar', type: 'from_value' }],
},
};
export const DefaultValues: ComponentStory<never> = () => {
// Initial placeholder props
const [props, setProps] =
useState<ComponentPropsWithoutRef<typeof OpenTelemetry>>(
loadingMetadataProps
);
// Simulate passing from the loading to the default state
useEffect(() => {
const timeoutId = setTimeout(() => {
setProps(metadataLoadedProps);
}, 100);
return () => clearTimeout(timeoutId);
}, []);
return <OpenTelemetry {...props} />;
};
DefaultValues.storyName =
'🧪 Testing - When a configuration is available, must use it as the default values';
DefaultValues.parameters = {
chromatic: { disableSnapshot: true },
};
// --------------------------------------------------
// INTERACTION TEST
// --------------------------------------------------
DefaultValues.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
// STEP: Ensure the props used for the test are different from the default ones
expect(metadataLoadedProps.metadataFormValues).not.toEqual(defaultFormValues);
// STEP: Wait until the metadata has been loaded (through waiting for the submit button being enabled)
const submitButton = await canvas.findByRole('button', { name: 'Update' });
await waitFor(() => {
expect(submitButton).toBeEnabled();
});
// STEP: check the default value of the Batch Size
const batchSizeInputField = await canvas.findByLabelText('Batch Size', {
selector: 'input',
});
expect(batchSizeInputField).toHaveValue(99);
// All the other input fields are not tested since if one input field has the correct default value
// all of the other input fields have the correct default value.
};
// #endregion

View File

@ -0,0 +1,59 @@
import * as React from 'react';
import type { FormValues } from './components/Form/schema';
import { defaultValues } from './components/Form/schema';
import { Form } from './components/Form/Form';
import { Header } from './components/Header/Header';
interface OpenTelemetryProps {
skeletonMode: boolean;
isFirstTimeSetup: boolean;
metadataFormValues: FormValues;
setOpenTelemetry: (formValues: FormValues) => Promise<void>;
}
/**
* All the OpenTelemetry page visual elements.
*/
export function OpenTelemetry(props: OpenTelemetryProps) {
const {
skeletonMode,
isFirstTimeSetup,
metadataFormValues,
setOpenTelemetry,
} = props;
const formValues = metadataFormValues || defaultValues;
let headerMode: 'enabled' | 'disabled' | 'skeleton' = 'enabled';
if (!metadataFormValues || !metadataFormValues.enabled)
headerMode = 'disabled';
if (skeletonMode) headerMode = 'skeleton';
return (
<div className="space-y-md max-w-screen-md p-md">
{/*
While the form is stateful and shows its own version of the OpenTelemetry config, the
Header reflects the real OpenTelemetry config stored in the metadata. It means that when
the users enable OpenTelemetry through the form toggle, the Header will show the "disabled"
status because OpenTelemetry is not enabled until the users submit the form. Once submitted,
and the metadata is invalidated and reloaded, then the Header will reflect the "enabled"
status.
*/}
<Header mode={headerMode} />
<div>
{/* This div avoid space-y-md separating too much the toggle and the form */}
<Form
onSubmit={setOpenTelemetry}
defaultValues={formValues}
skeletonMode={skeletonMode}
firstTimeSetup={isFirstTimeSetup}
/>
</div>
</div>
);
}

View File

@ -12,7 +12,7 @@ import { defaultValues } from './schema';
import { Form } from './Form';
export default {
title: 'Features/OpenTelemetryConfig/Form',
title: 'Features/OpenTelemetry/Form',
component: Form,
} as ComponentMeta<typeof Form>;
@ -50,6 +50,7 @@ Default.storyName = '💠 Default';
const defaultStoryArgs: ComponentPropsWithoutRef<typeof Form> = {
defaultValues,
skeletonMode: false,
firstTimeSetup: true,
onSubmit: action('onSubmit'),
};
Default.args = defaultStoryArgs;
@ -81,6 +82,7 @@ Skeleton.storyName = '💠 Skeleton';
const skeletonStoryArgs: ComponentPropsWithoutRef<typeof Form> = {
defaultValues,
skeletonMode: true,
firstTimeSetup: true,
onSubmit: action('onSubmit'),
};
@ -118,6 +120,7 @@ HappyPath.parameters = { chromatic: { disableSnapshot: true } };
const happyPathStoryArgs: ComponentPropsWithoutRef<typeof Form> = {
defaultValues,
skeletonMode: false,
firstTimeSetup: true,
onSubmit: action('onSubmit'),
};
HappyPath.args = happyPathStoryArgs;
@ -149,13 +152,9 @@ HappyPath.play = async ({ args, canvasElement }) => {
// STEP: Open the collapsible headers section
await userEvent.click(await canvas.findByText('Headers'));
// STEP: Check the first empty header is already there when the collapsible section is opened!
expect(
canvas.getByRole('textbox', { name: 'headers[0].name' })
).toBeInTheDocument();
expect(
canvas.getByRole('textbox', { name: 'headers[0].value' })
).toBeInTheDocument();
const addNewHeaderButton = canvas.getByText('Add Headers');
// STEP: Add one more header
await userEvent.click(addNewHeaderButton);
await userEvent.type(
canvas.getByRole('textbox', { name: 'headers[0].name' }),
@ -167,8 +166,7 @@ HappyPath.play = async ({ args, canvasElement }) => {
);
// STEP: Add one more header
const addNewRowButton = canvas.getByText('Add Headers');
await userEvent.click(addNewRowButton);
await userEvent.click(addNewHeaderButton);
// STEP: Add an env-var header
await userEvent.selectOptions(
@ -187,8 +185,11 @@ HappyPath.play = async ({ args, canvasElement }) => {
// STEP: Open the collapsible attributes section
await userEvent.click(await canvas.findByText('Attributes'));
const addNewAttributeButton = canvas.getByText('Add Attributes');
// STEP: Add one more attribute
await userEvent.click(addNewAttributeButton);
// STEP: Add an attribute
// ATTENTION: a first empty attributes should be already there when the collapsible section is opened!
await userEvent.type(
canvas.getByRole('textbox', { name: 'attributes[0].name' }),
'foo'
@ -199,7 +200,7 @@ HappyPath.play = async ({ args, canvasElement }) => {
);
// STEP: Click the Submit button
const submitButton = await canvas.findByRole('button', { name: 'Update' });
const submitButton = await canvas.findByRole('button', { name: 'Connect' });
await userEvent.click(submitButton);
});
@ -226,8 +227,54 @@ HappyPath.play = async ({ args, canvasElement }) => {
{ name: 'x-hasura-name', type: 'from_value', value: 'hasura_user' },
{ name: 'x-hasura-env', type: 'from_env', value: 'HASURA_USER' },
],
attributes: [{ name: 'foo', type: 'from_value', value: 'bar' }],
attributes: [{ name: 'foo', value: 'bar' }],
});
};
// #endregion
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// CONNECT BUTTON TEST
// #region
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// --------------------------------------------------
// STORY DEFINITION
// --------------------------------------------------
export const ConnectButton: ComponentStory<typeof Form> = args => (
<Form {...args} />
);
ConnectButton.storyName = `🧪 Testing - When it's not the first-time setup, the button should have the text "Update"`;
ConnectButton.parameters = { chromatic: { disableSnapshot: true } };
// --------------------------------------------------
// PROPS
// --------------------------------------------------
// Explicitly defining the story' args allows leveraging TS protection over them since story.args is
// a Partial<Props> and then developers cannot know that they break the story by changing the
// component props
const connectButtonStoryArgs: ComponentPropsWithoutRef<typeof Form> = {
defaultValues,
skeletonMode: false,
firstTimeSetup: false,
onSubmit: action('onSubmit'),
};
ConnectButton.args = connectButtonStoryArgs;
// --------------------------------------------------
// INTERACTION TEST
// --------------------------------------------------
ConnectButton.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const updateButton = await canvas.getByRole('button', { name: 'Update' });
expect(updateButton).toBeVisible();
};
// #endregion

View File

@ -12,13 +12,14 @@ import {
import { RequestHeadersSelector } from '@/new-components/RequestHeadersSelector';
import type { FormValues } from './schema';
import { formSchema } from './schema';
import { Toggle } from './components/Toggle';
import { useResetDefaultFormValues } from './hooks/useResetDefaultFormValues';
import { CollapsibleFieldWrapper } from './components/CollapsibleFieldWrapper';
interface FormProps {
skeletonMode: boolean;
firstTimeSetup: boolean;
defaultValues: FormValues;
onSubmit: (formValues: FormValues) => void;
}
@ -27,40 +28,50 @@ interface FormProps {
* The form to update the OpenTelemetry configuration.
*/
export function Form(props: FormProps) {
const { onSubmit, defaultValues, skeletonMode } = props;
const { onSubmit, defaultValues, firstTimeSetup, skeletonMode } = props;
const {
Form: ConsoleForm,
methods: { control },
methods: { control, reset },
} = useConsoleForm({
schema: formSchema,
options: { defaultValues },
});
useResetDefaultFormValues({ defaultValues, skeletonMode, reset });
const { isSubmitting } = useFormState({ control });
const buttonTexts = firstTimeSetup
? { text: 'Connect', loadingText: 'Connecting...' }
: { text: 'Update', loadingText: 'Updating...' };
return (
<ConsoleForm onSubmit={onSubmit}>
<ConsoleForm
onSubmit={data => {
onSubmit(data);
}}
>
<Toggle
name="enabled"
label="Status"
writtenStatus={{ true: 'Enabled', false: 'Disabled' }}
loading={skeletonMode}
/>
{/* No need to redact the input fields since Heap avoid recording the input field values by default */}
<InputField
name="endpoint"
label="Endpoint"
placeholder="Your OpenTelemetry endpoint"
tooltip="OpenTelemetry compliant receiver endpoint URL."
knowMoreLink="https://hasura.io/docs/latest/enterprise/opentelemetry/#endpoint"
clearButton
loading={skeletonMode}
/>
<Radio
name="connectionType"
label="Connection Type"
tooltip="The protocol to be used for the communication with the receiver. At the moment, only HTTP is supported."
knowMoreLink="https://hasura.io/docs/latest/enterprise/opentelemetry/#connection-type"
options={[{ value: 'http/protobuf', label: 'HTTP' }]}
loading={skeletonMode}
// At the beginning, only one Connection Type is available, hence it does not make sense
@ -73,6 +84,7 @@ export function Form(props: FormProps) {
name="dataType"
label="Data Type"
tooltip="The type of observability data points to be exported. At the moment, only Traces is supported."
knowMoreLink="https://hasura.io/docs/latest/enterprise/opentelemetry/#data-type"
options={[{ value: 'traces', label: 'Traces' }]}
loading={skeletonMode}
// At the beginning, only one Data Type ia available, hence it does not make sense
@ -80,50 +92,51 @@ export function Form(props: FormProps) {
// TODO: replace it with readonly when the input fields support it
disabled
/>
<InputField
name="batchSize"
type="number"
label="Batch Size"
placeholder="A number between 1 and 512"
tooltip="The maximum number of data points in an export request. The value should be between 1-512. Default value is 512."
knowMoreLink="https://hasura.io/docs/latest/enterprise/opentelemetry/#batch-size"
clearButton
loading={skeletonMode}
/>
<CollapsibleFieldWrapper
inputFieldName="headers"
label="Headers"
tooltip="Additional custom headers added to export request."
knowMoreLink="https://hasura.io/docs/latest/enterprise/opentelemetry/#headers"
loading={skeletonMode}
>
{/* No need to redact the input fields since Heap avoid recording the input field values by default */}
<RequestHeadersSelector name="headers" addButtonText="Add Headers" />
</CollapsibleFieldWrapper>
<CollapsibleFieldWrapper
inputFieldName="attributes"
label="Attributes"
tooltip="Additional custom tags added to export request."
knowMoreLink="https://hasura.io/docs/latest/enterprise/opentelemetry/#attributes"
loading={skeletonMode}
>
{/* No need to redact the input fields since Heap avoid recording the input field values by default */}
<RequestHeadersSelector
name="attributes"
addButtonText="Add Attributes"
typeSelect={false}
/>
</CollapsibleFieldWrapper>
<Button
type="submit"
mode="primary"
loadingText="Updating..."
loadingText={buttonTexts.loadingText}
isLoading={isSubmitting}
// Skeleton mode
disabled={skeletonMode}
// Necessary to separate the button from the above skeleton
className={clsx({ 'mt-1': skeletonMode })}
>
Update
{buttonTexts.text}
</Button>
</ConsoleForm>
);

View File

@ -6,7 +6,7 @@ import * as React from 'react';
import { CollapsibleFieldWrapper } from './CollapsibleFieldWrapper';
export default {
title: 'Features/OpenTelemetryConfig/Form/CollapsibleFieldWrapper',
title: 'Features/OpenTelemetry/Form/CollapsibleFieldWrapper',
component: CollapsibleFieldWrapper,
} as ComponentMeta<typeof CollapsibleFieldWrapper>;
@ -29,6 +29,7 @@ const storyArgs: ComponentPropsWithoutRef<typeof CollapsibleFieldWrapper> = {
inputFieldName: 'Input Field Name',
label: 'Input Field Label',
tooltip: 'Input Field Tooltip',
knowMoreLink: 'https://hasura.io/docs',
children: <i>Children go here</i>,
};
Default.args = storyArgs;

View File

@ -3,12 +3,14 @@ import Skeleton from 'react-loading-skeleton';
import { IconTooltip } from '@/new-components/Tooltip';
import { Collapsible } from '@/new-components/Collapsible';
import { KnowMoreLink } from '@/new-components/KnowMoreLink';
interface CollapsibleFieldWrapperProps {
inputFieldName: string;
label: string;
tooltip: string;
loading?: boolean;
knowMoreLink?: string;
}
/**
@ -25,30 +27,30 @@ interface CollapsibleFieldWrapperProps {
export const CollapsibleFieldWrapper: React.FC<
CollapsibleFieldWrapperProps
> = props => {
const { inputFieldName, label, tooltip, children, loading } = props;
const { inputFieldName, label, tooltip, children, loading, knowMoreLink } =
props;
if (loading) return <Skeleton className="h-8" />;
return (
<>
<Collapsible
triggerChildren={
<>
<label
htmlFor={inputFieldName}
className="block pt-1 text-gray-600 mb-xs pr-8 flex-grow220px"
>
<span className="flex items-center font-semibold">
<span>{label}</span>
<span className="font-normal ml-1">(Optional)</span>
<IconTooltip message={tooltip} />
</span>
</label>
</>
}
>
{children}
</Collapsible>
</>
<Collapsible
triggerChildren={
<label
htmlFor={inputFieldName}
className="block pt-1 text-gray-600 mb-xs pr-8 flex-grow220px"
>
<span className="flex items-center ">
<span className="font-semibold">{label}</span>
<span className="ml-1">(Optional)</span>
{/* TODO: solve the "button inside a button" a11y problem */}
<IconTooltip message={tooltip} />
{!!knowMoreLink && <KnowMoreLink href={knowMoreLink} />}
</span>
</label>
}
>
{children}
</Collapsible>
);
};

View File

@ -11,7 +11,7 @@ import { userEvent, within } from '@storybook/testing-library';
import { Toggle } from './Toggle';
export default {
title: 'Features/OpenTelemetryConfig/Toggle',
title: 'Features/OpenTelemetry/Toggle',
component: Toggle,
parameters: {

View File

@ -73,7 +73,7 @@ export const Toggle = <T extends z.infer<Schema>>({
error={maybeError}
renderDescriptionLineBreaks={renderDescriptionLineBreaks}
>
<div className={`flex ${className}`}>
<div className={`flex ${className ?? ''}`}>
<Switch
id={name}
aria-label={wrapperProps.label}

View File

@ -0,0 +1,44 @@
import type { UseFormReset } from 'react-hook-form';
import { useRef, useEffect } from 'react';
import type { FormValues } from '../schema';
type Params = {
skeletonMode: boolean;
defaultValues: FormValues;
reset: UseFormReset<FormValues>;
};
/**
* During the first metadata loading (aka when skeletonMode is true) the form is rendered but
* covered by the skeletons. When the skeletons disappear, the metadata-based form values must take
* place of the default (fake) ones. This could happen only through a form reset.
*
* Please note: this hook takes for granted that passing from skeletonMode=true to false means that
* the metadata is loaded. Also, at the time of writing, the skeletonMode never returns true anymore.
*/
export function useResetDefaultFormValues(params: Params) {
const { reset, defaultValues, skeletonMode } = params;
const latestParams = useRef({ defaultValues, reset });
latestParams.current = { defaultValues, reset };
const prevSkeletonMode = useRef(skeletonMode);
// Detect skeletonMode passing from true to false
useEffect(() => {
const wasInSkeletonMode = prevSkeletonMode.current;
const skeletonModeEnded = !skeletonMode;
const metadataLoadingIsCompleted = wasInSkeletonMode && skeletonModeEnded;
if (metadataLoadingIsCompleted) {
latestParams.current.reset(latestParams.current.defaultValues);
}
}, [skeletonMode]);
// The order matters! Updating prevSkeletonMode must happen after consuming it!
useEffect(() => {
prevSkeletonMode.current = skeletonMode;
}, [skeletonMode]);
}

View File

@ -73,9 +73,6 @@ export const defaultValues: FormValues = {
dataType: ['traces'],
// An empty element allows having the first line to be there when the collapsible opens.
// TODO: the behavior should be encapsulated in the RequestHeadersSelector because it's hard to
// maintain this UX when the server sends down the config without any headers...
headers: [{ name: '', type: 'from_value', value: '' }],
attributes: [{ name: '', type: 'from_value', value: '' }],
headers: [],
attributes: [],
};

View File

@ -6,7 +6,7 @@ import { BadgeDisabled as BadgeDisabledComponent } from './BadgeDisabled';
import { BadgeSkeleton as BadgeSkeletonComponent } from './BadgeSkeleton';
export default {
title: 'Features/OpenTelemetryConfig/Badge',
title: 'Features/OpenTelemetry/Badge',
};
// --------------------------------------------------

View File

@ -4,7 +4,7 @@ import * as React from 'react';
import { Header } from './Header';
export default {
title: 'Features/OpenTelemetryConfig/Header',
title: 'Features/OpenTelemetry/Header',
};
// --------------------------------------------------

View File

@ -6,7 +6,7 @@ import { HeroDisabled as HeroDisabledComponent } from './HeroDisabled';
import { HeroSkeleton as HeroSkeletonComponent } from './HeroSkeleton';
export default {
title: 'Features/OpenTelemetryConfig/Hero',
title: 'Features/OpenTelemetry/Hero',
};
// --------------------------------------------------

View File

@ -0,0 +1,15 @@
import * as React from 'react';
import { isProLiteConsole } from '@/utils';
import { OpenTelemetryProvider } from './OpenTelemetryProvider/OpenTelemetryProvider';
export function OpenTelemetryFeature() {
// Please note: checking where the feature is visible or not should not be in the scope of the
// feature itself. Theoretically, the router's config should avoid managing the route in some
// circumstances, and the sidebar button should be rendered or not based on the same condition.
// But the feature itself should not be aware of when it's rendered or not.
// eslint-disable-next-line no-underscore-dangle
if (!isProLiteConsole(window.__env)) return null;
return <OpenTelemetryProvider />;
}

View File

@ -0,0 +1,130 @@
import type { ComponentStory, ComponentMeta } from '@storybook/react';
import * as React from 'react';
import produce from 'immer';
import { expect } from '@storybook/jest';
import { act } from '@testing-library/react';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { ReduxDecorator } from '@/storybook/decorators/redux-decorator';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { createDefaultInitialData, handlers } from '@/mocks/metadata.mock';
import { OpenTelemetryProvider } from './OpenTelemetryProvider';
// --------------------------------------------------
// NOT TESTED
// --------------------------------------------------
// The following scenarios do not have interaction tests because...:
// - The success and error notification: because it's better to test it through a browser test until showing the notifications in Storybook is not possible
export default {
title: 'Features/OpenTelemetry/OpenTelemetryProvider',
component: OpenTelemetryProvider,
decorators: [
ReduxDecorator({ tables: { currentDataSource: 'default' } }),
ReactQueryDecorator(),
],
parameters: {
msw: handlers({
// Allows to see the loading states
delay: 500,
// This story requires just the OpenTelemetry-related metadata handlers
initialData: produce(createDefaultInitialData(), draft => {
draft.metadata.opentelemetry = undefined;
}),
}),
},
} as ComponentMeta<typeof OpenTelemetryProvider>;
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// DEFAULT STORY
// #region
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// --------------------------------------------------
// STORY DEFINITION
// --------------------------------------------------
export const Default: ComponentStory<typeof OpenTelemetryProvider> = () => {
return <OpenTelemetryProvider />;
};
Default.storyName = '💠 Default';
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// HAPPY PATH TEST
// #region
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// --------------------------------------------------
// STORY DEFINITION
// --------------------------------------------------
export const HappyPath: ComponentStory<typeof OpenTelemetryProvider> = () => (
<OpenTelemetryProvider />
);
HappyPath.storyName =
'🧪 Testing - When enable OpenTelemetry, it should update the OpenTelemetry metadata';
HappyPath.parameters = {
chromatic: { disableSnapshot: true },
msw: handlers({
// Speeds up the test as much as possible
delay: 0,
// This story requires just the OpenTelemetry-related metadata handlers
initialData: produce(createDefaultInitialData(), draft => {
draft.metadata.opentelemetry = undefined;
}),
}),
};
// --------------------------------------------------
// INTERACTION TEST
// --------------------------------------------------
HappyPath.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
// STEP: Wait until the metadata has been loaded (through waiting for the submit button being enabled)
const submitButton = await canvas.findByRole('button', { name: 'Connect' });
await waitFor(() => {
expect(submitButton).toBeEnabled();
});
// STEP: Check the badge shows OpenTelemetry is disabled
const badge = await canvas.findByTestId('badge');
expect(badge).toHaveTextContent('Disabled');
// act avoids the "When testing, code that causes React state updates should be wrapped into act(...):" error
await act(async () => {
// STEP: Enable OpenTelemetry
await userEvent.click(await canvas.findByLabelText('Status'));
// STEP: Type the Endpoint
await userEvent.type(
await canvas.findByLabelText('Endpoint', { selector: 'input' }),
'http://hasura.io'
);
// STEP: Click the Submit button
await userEvent.click(submitButton);
});
// STEP: Wait for OpenTelemetry to be enabled (through waiting for the badge to show "Enabled"
// since the badge update only after updating the metadata and reloading it)
await waitFor(async () => {
expect(await canvas.findByTestId('badge')).toHaveTextContent('Enabled');
});
};
// #endregion

View File

@ -0,0 +1,26 @@
import * as React from 'react';
import { OpenTelemetry } from '../OpenTelemetry/OpenTelemetry';
import { useOpenTelemetry } from './hooks/useOpenTelemetry';
import { useSetOpenTelemetry } from './hooks/useSetOpenTelemetry';
/**
* Allow isolating OpenTelemetry (the UI core of the feature) from every ap logic like
* notifications, metadata loading, etc.
*/
export function OpenTelemetryProvider() {
const { isLoadingMetadata, metadataFormValues, isFirstTimeSetup } =
useOpenTelemetry();
const { setOpenTelemetry } = useSetOpenTelemetry();
return (
<OpenTelemetry
isFirstTimeSetup={isFirstTimeSetup}
skeletonMode={isLoadingMetadata}
metadataFormValues={metadataFormValues}
setOpenTelemetry={setOpenTelemetry}
/>
);
}

View File

@ -0,0 +1,24 @@
import { useFireNotification } from '@/new-components/Notifications';
import { useEffect } from 'react';
/**
* In case of metadata loading failure, the users cannot do anything but reloading the page.
* ATTENTION: At the time of writing, there is not a common way to handle metadata failures.
*/
export function useNotifyMetadataLoadingError(loadingMetadataFailed: boolean) {
const { fireNotification } = useFireNotification();
useEffect(() => {
if (!loadingMetadataFailed) return;
fireNotification({
title: 'Error!',
message: 'Failed to load the metadata. Please reload the page.',
type: 'error',
});
}, [
// fireNotification is a stable reference
fireNotification,
loadingMetadataFailed,
]);
}

View File

@ -0,0 +1,91 @@
import {
trackCustomEvent,
programmaticallyTraceError,
} from '@/features/Analytics';
import {
parseUnexistingEnvVarSchemaError,
parseHasuraEnvVarsNotAllowedError,
} from '@/features/hasura-metadata-types';
import { useFireNotification } from '@/new-components/Notifications';
export function useOnSetOpenTelemetryError(
fireNotification: ReturnType<typeof useFireNotification>['fireNotification']
) {
return function onSetOpenTelemetryError(err: unknown) {
// UNEXISTING ENV VAR ERROR
const parseUnexistingEnvVarSchemaResult =
parseUnexistingEnvVarSchemaError(err);
if (parseUnexistingEnvVarSchemaResult.success) {
fireNotification({
type: 'error',
title: 'Error!',
message: parseUnexistingEnvVarSchemaResult.data.internal[0].reason,
});
trackCustomEvent(
{
location: 'OpenTelemetry',
action: 'update OpenTelemetry',
object: 'Unexisting env var error',
},
{
severity: 'error',
}
);
return;
}
// HASURA ENV VAR ERROR
const parseHasuraEnvVarsNotAllowedResult =
parseHasuraEnvVarsNotAllowedError(err);
if (parseHasuraEnvVarsNotAllowedResult.success) {
fireNotification({
type: 'error',
title: 'Error!',
message: parseHasuraEnvVarsNotAllowedResult.data.error,
});
trackCustomEvent(
{
location: 'OpenTelemetry',
action: 'update OpenTelemetry',
object: 'Hasura env var error',
},
{
severity: 'error',
}
);
return;
}
// UNEXPECTED ERROR
fireNotification({
type: 'error',
title: 'Error!',
message: JSON.stringify(err),
});
trackCustomEvent(
{
location: 'OpenTelemetry',
action: 'update OpenTelemetry',
object: 'Unexpected error',
},
{
severity: 'error',
}
);
programmaticallyTraceError(
new Error(
'OpenTelemetry set_opentelemetry_config error not parsed',
// @ts-expect-error This error will automatically disappear with Nx that targets new browsers by default
{ cause: err }
)
);
};
}

View File

@ -0,0 +1,27 @@
import { useMetadata } from '@/features/hasura-metadata-api';
import { openTelemetryToFormValues } from '../utils/openTelemetryToFormValues';
import { useNotifyMetadataLoadingError } from './useNotifyMetadataLoadingError';
import { useTrackTypeMisalignments } from './useTrackTypeMisalignments';
/**
* Retrieve the OpenTelemetry configuration from the metadata.
*/
export function useOpenTelemetry() {
const {
data: openTelemetry,
isLoading: isLoadingMetadata,
isError: loadingMetadataFailed,
} = useMetadata(metadata => metadata.metadata.opentelemetry);
useNotifyMetadataLoadingError(loadingMetadataFailed);
useTrackTypeMisalignments(openTelemetry);
const metadataFormValues = openTelemetryToFormValues(openTelemetry);
return {
isFirstTimeSetup: !openTelemetry,
isLoadingMetadata,
metadataFormValues,
};
}

View File

@ -0,0 +1,75 @@
import type { SetOpenTelemetryQuery } from '@/features/hasura-metadata-types';
import {
useMetadataVersion,
useMetadataMigration,
} from '@/features/MetadataAPI';
import { useFireNotification } from '@/new-components/Notifications';
import { useInvalidateMetadata } from '@/features/hasura-metadata-api';
import type { FormValues } from '../../OpenTelemetry/components/Form/schema';
import { formValuesToOpenTelemetry } from '../utils/openTelemetryToFormValues';
import { useOnSetOpenTelemetryError } from './useOnSetOpenTelemetryError';
type QueryArgs = SetOpenTelemetryQuery['args'];
function errorTransform(error: unknown) {
return error;
}
/**
* Allow updating the OpenTelemetry configuration.
*/
export function useSetOpenTelemetry() {
const mutation = useMetadataMigration({ errorTransform });
const { data: version } = useMetadataVersion();
const invalidateMetadata = useInvalidateMetadata();
const { fireNotification } = useFireNotification();
const onSetOpenTelemetryError = useOnSetOpenTelemetryError(fireNotification);
const setOpenTelemetry = (formValues: FormValues) => {
const args: QueryArgs = formValuesToOpenTelemetry(formValues);
// Please note: not checking if the component is still mounted or not is made on purpose because
// the callbacks do not direct mutate any component state.
return new Promise<void>(resolve => {
mutation.mutate(
{
query: {
type: 'set_opentelemetry_config',
args,
resource_version: version,
},
},
{
onSuccess: () => {
resolve();
invalidateMetadata();
fireNotification({
title: 'Success!',
message: 'Successfully updated the OpenTelemetry Configuration',
type: 'success',
});
},
onError: err => {
// The promise is used by Rect hook form to stop show the loading spinner but React hook
// form must not handle errors.
resolve();
onSetOpenTelemetryError(err);
},
}
);
});
};
return {
setOpenTelemetry,
};
}

View File

@ -0,0 +1,68 @@
import type { Metadata } from '@/features/hasura-metadata-types';
import { parseOpenTelemetry } from '@/features/hasura-metadata-types';
import {
programmaticallyTraceError,
trackCustomEvent,
} from '@/features/Analytics';
/**
* Parse the OpenTelemetry config stored in metadata. There are two possibilities where server's
* metadata is not aligned with the Console one:
* 1. (NOT CAUGHT) When the server adds more data than the expected ones. In this case, the Console
* for sure will lose the extra data because the immutability nature of the Console brings to never
* work with the original object. Even if catching this error would be a nice to have, it's not easy
* to implement since zod's passThrough is not easy to be used along the whole data-management chain.
* Zod's strict(), instead, is not useable with discriminated unions.
* 2. (CAUGHT) When the server removes/changes some data. In this case, the Console reports the error
* in Sentry.
*
* Theoretically speaking, every misalignment between the server and the Console is a huge problem
* (especially if the server object contains less properties than what the Console expects) and
* breaking the Console through an error could be the best thing to do (instead of using
* programmaticallyTraceError). Anyway, most of the misalignments could be related to the validation
* of the single properties (ex. the endpoint being `null` instead an empty string). In case of these
* soft misalignments, the Console could continue to work as expected, hence it does not makes sense
* to break it. The Console folks will notice the error in Sentry.
*
* PLEASE NOTE: The problem this function tries to work around is a pure communication problem that
* happens only if the server and the Console are not aligned. When the TypeScript types coming from
* the OpenAPI specs will be there, we do not need this function anymore.
*
* PEASE NOTE: Validating the metadata should happen in a centralized manner, not in every single
* feature... Because, at the moment, the OpenTelemetry part never gets validated until the users
* navigates to the OpenTelemetry page.
*/
export function useTrackTypeMisalignments(
openTelemetry: Metadata['metadata']['opentelemetry']
) {
// metadata.opentelemetry is not there if the users never set it up
if (openTelemetry === undefined) return;
const result = parseOpenTelemetry(openTelemetry);
if (result.success) return;
trackCustomEvent(
{
location: 'OpenTelemetry',
action: 'parse',
object: 'OpenTelemetry parser',
},
{
severity: 'error',
message: result.error.message,
data: {
openTelemetry: JSON.stringify(openTelemetry),
},
}
);
programmaticallyTraceError(
new Error(
'OpenTelemetry metadata not parsed',
// @ts-expect-error This error will automatically disappear with Nx that targets new browsers by default
{ cause: result.error }
)
);
}

View File

@ -1,5 +1,5 @@
import type { OpenTelemetry } from '@/features/hasura-metadata-types';
import type { FormValues } from '../../../OpenTelemetryConfig/components/Form/schema';
import type { FormValues } from '../../../OpenTelemetry/components/Form/schema';
type MetadataAttributes = OpenTelemetry['exporter_otlp']['resource_attributes'];
type FormAttributes = FormValues['attributes'];

View File

@ -1,5 +1,5 @@
import type { OpenTelemetry } from '@/features/hasura-metadata-types';
import type { FormValues } from '../../../OpenTelemetryConfig/components/Form/schema';
import type { FormValues } from '../../../OpenTelemetry/components/Form/schema';
type MetadataHeaders = OpenTelemetry['exporter_otlp']['headers'];
type FormHeaders = FormValues['headers'];

View File

@ -0,0 +1,78 @@
import type { Metadata } from '@/features/hasura-metadata-types';
import type { FormValues } from '../../OpenTelemetry/components/Form/schema';
import {
formValuesToOpenTelemetry,
openTelemetryToFormValues,
} from './openTelemetryToFormValues';
describe('openTelemetryToFormValues', () => {
const openTelemetry: 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 OpenTelemetry, should return the same values for the form', () => {
expect(openTelemetryToFormValues(openTelemetry)).toEqual(formValues);
});
it('When passed with some form values, should return the same values for the OpenTelemetry', () => {
expect(formValuesToOpenTelemetry(formValues)).toEqual(openTelemetry);
});
it('When passed with a disabled configuration and an empty endpoint, should strip out the endpoint from the OpenTelemetry that must be sent to the server', () => {
expect(
formValuesToOpenTelemetry({
enabled: false,
batchSize: 100,
attributes: [],
endpoint: '',
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',
})
).toEqual({
status: 'disabled',
exporter_otlp: {
resource_attributes: [],
protocol: 'http/protobuf',
headers: [{ name: 'baz', value: 'qux' }],
},
data_types: ['traces'],
batch_span_processor: {
max_export_batch_size: 100,
},
});
});
});

View File

@ -1,7 +1,7 @@
import type { Metadata, OpenTelemetry } from '@/features/hasura-metadata-types';
import type { FormValues } from '../../OpenTelemetryConfig/components/Form/schema';
import type { FormValues } from '../../OpenTelemetry/components/Form/schema';
import { defaultValues } from '../../OpenTelemetryConfig/components/Form/schema';
import { defaultValues } from '../../OpenTelemetry/components/Form/schema';
import {
formHeadersToMetadataHeaders,
@ -16,24 +16,24 @@ import {
* 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!
* the type! Misalignments, if any, must be caught before calling openTelemetryToFormValues!
*/
export function openTelemetryConfigToFormValues(
openTelemetryConfig: Metadata['metadata']['opentelemetry']
export function openTelemetryToFormValues(
openTelemetry: Metadata['metadata']['opentelemetry']
): FormValues {
if (!openTelemetryConfig) return defaultValues;
if (!openTelemetry) return defaultValues;
if (openTelemetryConfig.status === 'disabled') {
if (openTelemetry.status === 'disabled') {
return {
enabled: false,
endpoint: openTelemetryConfig.exporter_otlp.otlp_traces_endpoint ?? '',
endpoint: openTelemetry.exporter_otlp.otlp_traces_endpoint ?? '',
headers: metadataHeadersToFormHeaders(
openTelemetryConfig.exporter_otlp.headers
openTelemetry.exporter_otlp.headers
),
batchSize: openTelemetryConfig.batch_span_processor.max_export_batch_size,
batchSize: openTelemetry.batch_span_processor.max_export_batch_size,
attributes: metadataAttributesToFormAttributes(
openTelemetryConfig.exporter_otlp.resource_attributes
openTelemetry.exporter_otlp.resource_attributes
),
// At the beginning, only one Data Type is available
@ -45,14 +45,12 @@ export function openTelemetryConfigToFormValues(
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,
endpoint: openTelemetry.exporter_otlp.otlp_traces_endpoint,
headers: metadataHeadersToFormHeaders(openTelemetry.exporter_otlp.headers),
batchSize: openTelemetry.batch_span_processor.max_export_batch_size,
attributes: metadataAttributesToFormAttributes(
openTelemetryConfig.exporter_otlp.resource_attributes
openTelemetry.exporter_otlp.resource_attributes
),
// At the beginning, only one Data Type is available
@ -65,7 +63,7 @@ export function openTelemetryConfigToFormValues(
/**
* Convert the form values their corresponding metadata object.
*/
export function formValuesToOpenTelemetryConfig(
export function formValuesToOpenTelemetry(
formValues: FormValues
): OpenTelemetry {
const otlp_traces_endpoint = formValues.endpoint;
@ -80,7 +78,7 @@ export function formValuesToOpenTelemetryConfig(
);
if (!formValues.enabled) {
return {
const disabledOpenTelemetry: OpenTelemetry = {
status: 'disabled',
// At the beginning, only one Data Type is available
@ -90,13 +88,18 @@ export function formValuesToOpenTelemetryConfig(
headers,
protocol,
resource_attributes,
otlp_traces_endpoint,
},
batch_span_processor: {
max_export_batch_size,
},
};
if (otlp_traces_endpoint) {
disabledOpenTelemetry.exporter_otlp.otlp_traces_endpoint =
otlp_traces_endpoint;
}
return disabledOpenTelemetry;
}
return {

View File

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

View File

@ -4,11 +4,10 @@ import type { MetadataReducer } from '@/mocks/actions';
import type { allowedMetadataTypes } from '@/features/MetadataAPI';
import type {
Metadata,
SetOpenTelemetryConfigQuery,
SetOpenTelemetryQuery,
} from '@/features/hasura-metadata-types';
import { parseOpenTelemetry } from '@/features/hasura-metadata-types';
export const openTelemetryConfigInitialData: Partial<Metadata['metadata']> = {
export const openTelemetryInitialData: Partial<Metadata['metadata']> = {
opentelemetry: undefined,
};
@ -17,17 +16,12 @@ export const metadataHandlers: Partial<
> = {
// 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 newOpenTelemetry = action.args as SetOpenTelemetryQuery['args'];
const result = parseOpenTelemetry(newOpenTelemetryConfig);
if (!result.success) {
throw result.error;
}
// TODO: manage error if needed
return produce(state, draft => {
draft.metadata.opentelemetry = newOpenTelemetryConfig;
draft.metadata.opentelemetry = newOpenTelemetry;
});
},
};

View File

@ -1,43 +0,0 @@
import * as React from 'react';
import type { FormValues } from './components/Form/schema';
import { defaultValues } from './components/Form/schema';
import { Form } from './components/Form/Form';
import { Header } from './components/Header/Header';
interface OpenTelemetryConfigProps {
skeletonMode: boolean;
metadataFormValues: FormValues | undefined;
updateOpenTelemetry: (formValues: FormValues) => Promise<void>;
}
/**
* All the OpenTelemetry page without any external dependency.
*/
export function OpenTelemetryConfig(props: OpenTelemetryConfigProps) {
const { skeletonMode, metadataFormValues, updateOpenTelemetry } = props;
const formValues = metadataFormValues || defaultValues;
const headerMode = skeletonMode
? 'skeleton'
: !formValues.enabled
? 'disabled'
: 'enabled';
return (
<div className="space-y-md max-w-screen-md p-md">
<Header mode={headerMode} />
<div>
{/* This div avoid space-y-md separating too much the toggle and the form */}
<Form
onSubmit={updateOpenTelemetry}
defaultValues={formValues}
skeletonMode={skeletonMode}
/>
</div>
</div>
);
}

View File

@ -1,51 +0,0 @@
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

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

View File

@ -0,0 +1,65 @@
import {
parseUnexistingEnvVarSchemaError,
parseHasuraEnvVarsNotAllowedError,
} from './parsers';
// Testing parseUnexistingEnvVarSchemaError is important until it's based on the custom regex
describe('parseUnexistingEnvVarSchemaError', () => {
it('When invoked with a "unexistingEnvVar" error, then should return it', () => {
const theRealServerError = {
code: 'unexpected',
error: 'cannot continue due to new inconsistent metadata',
internal: [
{
definition: {
headers: [{ name: 'foo', value_from_env: 'baz' }],
otlp_traces_endpoint: 'http://example.io',
protocol: 'http/protobuf',
resource_attributes: [],
},
name: 'open_telemetry exporter_otlp',
reason: "Inconsistent object: environment variable 'baz' not set",
type: 'open_telemetry',
},
],
path: '$.args',
};
const theErrorTheConsoleMatters = {
internal: [
{
reason: "Inconsistent object: environment variable 'baz' not set",
},
],
};
const result = parseUnexistingEnvVarSchemaError(theRealServerError);
expect(result).toEqual({
success: true,
data: theErrorTheConsoleMatters,
});
});
});
// Testing parseHasuraEnvVarsNotAllowedError is important until it's based on the custom regex
describe('parseHasuraEnvVarsNotAllowedError', () => {
it('When invoked with a "hasuraEnvVarsNotAllowed" error, then should return it', () => {
const theRealServerError = {
code: 'parse-failed',
error:
'env variables starting with "HASURA_GRAPHQL_" are not allowed in value_from_env: HASURA_GRAPHQL_ENABLED_APIS',
path: '$.args.exporter_otlp.headers[1]',
};
const theErrorTheConsoleMatters = {
error:
'env variables starting with "HASURA_GRAPHQL_" are not allowed in value_from_env: HASURA_GRAPHQL_ENABLED_APIS',
};
const result = parseHasuraEnvVarsNotAllowedError(theRealServerError);
expect(result).toEqual({
success: true,
data: theErrorTheConsoleMatters,
});
});
});

View File

@ -1,4 +1,8 @@
import { openTelemetrySchema } from '.';
import {
openTelemetrySchema,
unexistingEnvVarSchema,
hasuraEnvVarsNotAllowedSchema,
} from '.';
/**
* Allow to parse the data the server returns and early catch possible console/server misalignments.
@ -14,3 +18,10 @@ import { openTelemetrySchema } from '.';
export function parseOpenTelemetry(object: unknown) {
return openTelemetrySchema.safeParse(object);
}
export function parseUnexistingEnvVarSchemaError(errorBody: unknown) {
return unexistingEnvVarSchema.safeParse(errorBody);
}
export function parseHasuraEnvVarsNotAllowedError(errorBody: unknown) {
return hasuraEnvVarsNotAllowedSchema.safeParse(errorBody);
}

View File

@ -1,6 +1,7 @@
import { z } from 'zod';
import type { OpenTelemetry } from './types';
export type OpenTelemetryQueries = SetOpenTelemetryConfigQuery['type'];
export type OpenTelemetryQueries = SetOpenTelemetryQuery['type'];
// --------------------------------------------------
// SET OPENTELEMETRY CONFIG
@ -9,14 +10,14 @@ export type OpenTelemetryQueries = SetOpenTelemetryConfigQuery['type'];
/**
* Allow to set/update the OpenTelemetry configuration.
*/
export type SetOpenTelemetryConfigQuery = {
export type SetOpenTelemetryQuery = {
type: 'set_opentelemetry_config';
args: OpenTelemetry;
successResponse: { message: 'success' };
errorResponse: SetOpenTelemetryConfigQueryErrors;
errorResponse: SetOpenTelemetryQueryErrors;
};
// ERRORS
@ -26,15 +27,75 @@ export type SetOpenTelemetryConfigQuery = {
// - 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;
export type SetOpenTelemetryQueryErrors = NotPreventedByTheConsoleErrors;
type ErrorsNotManagedByTheConsole = {
type NotPreventedByTheConsoleErrors = {
httpStatus: 400;
} & {
code: 'parse-failed';
error: `Environment variable not found: "${string}"`;
path: '$.args';
body:
| z.infer<typeof hasuraEnvVarsNotAllowedSchema>
| z.infer<typeof unexistingEnvVarSchema>
| UnknownError;
};
// Even in case of multiple HASURA_GRAPHQL_ env vars used, the server always report just one of them
// in the error.
export const hasuraEnvVarsNotAllowedSchema = z.object({
// ATTENTION: the real server error contains more data, but it's useless to type it because it only
// makes this schema more fragile. At the time of writing, the errors are not set in stone and can
// change in the future.
error:
// the use of z.custom is not more needed when this issue will be fixed: https://github.com/colinhacks/zod/issues/419
z.custom<`env variables starting with "HASURA_GRAPHQL_" are not allowed in value_from_env: HASURA_GRAPHQL_${EnvVarName}`>(
val => {
if (typeof val !== 'string') return false;
// see: https://regex101.com/r/afZdol/1
return /^env variables starting with \"HASURA_GRAPHQL_\" are not allowed in value_from_env: HASURA_GRAPHQL_(?<envVarName>.[A-Z_]+)$/.test(
val
);
}
),
// It would be nice to highlight the problematic env var in the Console's form but, at the time of
// writing, the RequestHeadersSelector component is not connected to the form and cannot show
// the input field errors. Hence the `path` returned by the server is currently useless.
// path: `$.args.exporter_otlp.headers[${Base0Index}]`, // Base0Index = number
});
// Even in case of multiple unexisting env vars, the server always report just one of them in the
// reason.
export const unexistingEnvVarSchema = z.object({
// ATTENTION: the real server error contains more data, but it's useless to type it because it only
// makes this schema more fragile. At the time of writing, the errors are not set in stone and can
// change in the future.
internal: z.array(
z.object({
reason:
z.custom<`Inconsistent object: environment variable '${EnvVarName}' not set`>(
val => {
if (typeof val !== 'string') return false;
// see: https://regex101.com/r/CBCkEd/1
return /Inconsistent object: environment variable '(?<envVarName>.*?)' not set/.test(
val
);
}
),
})
),
// It would be nice to highlight the problematic env var in the Console's form but, at the time of
// writing, the RequestHeadersSelector component is not connected to the form and cannot show
// the input field errors. More, the server does not get the name of the problematic var so only
// parsing the error message is possible (but it's better off to avoid it).
});
type UnknownError = unknown;
type EnvVarName = string;
/^env variables starting with \\"HASURA_GRAPHQL_\\" are not allowed in value_from_env: HASURA_GRAPHQL_(?<envVarName>.[A-Z_]+)$/.test(
'env variables starting with "HASURA_GRAPHQL_" are not allowed in value_from_env: HASURA_GRAPHQL_ENABLED_APIS'
);

View File

@ -102,9 +102,11 @@ const disabledOpenTelemetrySchema = {
status: z.literal('disabled'),
exporter_otlp: exporterSchema.partial({
exporter_otlp: exporterSchema.extend({
// If OpenTelemetry is disabled, the endpoint is not required
otlp_traces_endpoint: true,
otlp_traces_endpoint: validUrlSchema
.or(z.literal(''))
.or(z.literal(undefined)),
}),
};

View File

@ -172,5 +172,22 @@ describe('API functions', () => {
});
});
});
describe('errorTransform', () => {
it('when errorTransform is passed, should call the errorTransform function with the raw error', async () => {
server.use(
rest.get(url, (_req, res, context) => {
// Make every request failing with { foo: 'bar' }
return res(context.json({ foo: 'bar' }), context.status(400));
})
);
const errorTransform = jest.fn(error => error);
const promise = Api.get({ url, headers }, undefined, errorTransform);
await expect(promise).rejects.toEqual({ foo: 'bar' });
expect(errorTransform).toBeCalledWith({ foo: 'bar' });
});
});
});
});

View File

@ -14,7 +14,16 @@ interface IApiArgs {
async function fetchApi<T = unknown, V = T>(
args: IApiArgs,
dataTransform?: (data: T) => V
dataTransform?: (data: T) => V,
/**
* OpenTelemetry offers some new error objects because it uses a new server logic to validate
* the users input (pease look at the OpenTelemetry errors to gather more info about the errors).
* At the time of writing, the real reason of the failure would be hidden by the `APIError.fromUnknown`
* function. At the same time, the OpenTelemetry errors are not a standard yet on the server, so
* they need a custom management. As/if the new format will be used more, it would be better to
* remove this custom function to avoid the proliferation of custom usages.
*/
errorTransform?: (error: unknown) => unknown
): Promise<V> {
try {
const { headers, url, method, body, credentials } = args;
@ -50,6 +59,10 @@ async function fetchApi<T = unknown, V = T>(
const unknownError = await response.text();
throw unknownError;
} catch (error) {
if (errorTransform) {
throw errorTransform(error);
}
throw APIError.fromUnknown(error);
}
}
@ -57,32 +70,53 @@ async function fetchApi<T = unknown, V = T>(
export namespace Api {
export function get<T = unknown, V = T>(
args: Omit<IApiArgs, 'body' | 'method'>,
dataTransform?: (data: T) => V
dataTransform?: (data: T) => V,
errorTransform?: (error: unknown) => unknown
) {
return fetchApi<T, V>({ ...args, method: 'GET' }, dataTransform);
return fetchApi<T, V>(
{ ...args, method: 'GET' },
dataTransform,
errorTransform
);
}
export function post<T = unknown, V = T>(
args: Omit<IApiArgs, 'method'>,
dataTransform?: (data: T) => V
dataTransform?: (data: T) => V,
errorTransform?: (error: unknown) => unknown
) {
return fetchApi<T, V>({ ...args, method: 'POST' }, dataTransform);
return fetchApi<T, V>(
{ ...args, method: 'POST' },
dataTransform,
errorTransform
);
}
export function put<T = unknown, V = T>(
args: Omit<IApiArgs, 'method'>,
dataTransform?: (data: T) => V
dataTransform?: (data: T) => V,
errorTransform?: (error: unknown) => unknown
) {
return fetchApi<T, V>({ ...args, method: 'PUT' }, dataTransform);
return fetchApi<T, V>(
{ ...args, method: 'PUT' },
dataTransform,
errorTransform
);
}
export function del<T = unknown, V = T>(
args: Omit<IApiArgs, 'method'>,
dataTransform?: (data: T) => V
dataTransform?: (data: T) => V,
errorTransform?: (error: unknown) => unknown
) {
return fetchApi<T, V>({ ...args, method: 'DELETE' }, dataTransform);
return fetchApi<T, V>(
{ ...args, method: 'DELETE' },
dataTransform,
errorTransform
);
}
export function base<T = unknown, V = T>(
args: IApiArgs,
dataTransform?: (data: T) => V
dataTransform?: (data: T) => V,
errorTransform?: (error: unknown) => unknown
) {
return fetchApi<T, V>(args, dataTransform);
return fetchApi<T, V>(args, dataTransform, errorTransform);
}
}

View File

@ -4,7 +4,7 @@ import { allowedMetadataTypes } from '@/features/MetadataAPI';
import { metadataHandlers as allowListMetadataHandlers } from '@/features/AllowLists';
import { metadataHandlers as adhocEventMetadataHandlers } from '@/features/AdhocEvents';
import { metadataHandlers as queryCollectionMetadataHandlers } from '@/features/QueryCollections';
import { metadataHandlers as openTelemetryMetadataHandlers } from '@/features/OpenTelemetryConfig';
import { metadataHandlers as openTelemetryMetadataHandlers } from '@/features/OpenTelemetry';
import { TMigration } from '../features/MetadataAPI/hooks/useMetadataMigration';

View File

@ -4,7 +4,7 @@ import type { Metadata } from '@/features/hasura-metadata-types';
import { allowListInitialData } from '@/features/AllowLists';
import { queryCollectionInitialData } from '@/features/QueryCollections';
import { openTelemetryConfigInitialData } from '@/features/OpenTelemetryConfig';
import { openTelemetryInitialData } from '@/features/OpenTelemetry';
import { rest } from 'msw';
import { metadataReducer } from './actions';
@ -17,7 +17,7 @@ export const createDefaultInitialData = (): Metadata => ({
inherited_roles: [],
...allowListInitialData,
...queryCollectionInitialData,
...openTelemetryConfigInitialData,
...openTelemetryInitialData,
},
});

View File

@ -33,6 +33,7 @@ export const Badge: React.FC<React.PropsWithChildren<BadgeProps>> = ({
}) => {
return (
<span
data-testid="badge"
{...rest}
className={clsx(
'inline-flex items-center px-sm py-0.5 rounded-full text-sm tracking-wide font-semibold',

View File

@ -105,6 +105,26 @@ VariantWithDescriptionAndTooltip.parameters = {
},
};
export const VariantWithDescriptionAndTooltipAndKnwMoreLink: ComponentStory<
typeof FieldWrapper
> = () => (
<FieldWrapper
label="The field wrapper label"
description="The field wrapper description"
tooltip="The field wrapper tooltip"
knowMoreLink="https://hasura.io/docs"
>
<ChildrenExample />
</FieldWrapper>
);
VariantWithDescriptionAndTooltipAndKnwMoreLink.storyName =
'🎭 Variant - With description, tooltip, and know more link';
VariantWithDescriptionAndTooltipAndKnwMoreLink.parameters = {
docs: {
source: { state: 'open' },
},
};
export const StateLoading: ComponentStory<typeof FieldWrapper> = () => (
<FieldWrapper
label="The field wrapper label"

View File

@ -5,6 +5,7 @@ import Skeleton from 'react-loading-skeleton';
import { FaExclamationCircle } from 'react-icons/fa';
import { IconTooltip } from '@/new-components/Tooltip';
import { KnowMoreLink } from '../KnowMoreLink';
type FieldWrapperProps = {
/**
@ -40,10 +41,6 @@ type FieldWrapperProps = {
* The field description
*/
description?: string;
/**
* The field tooltip label
*/
tooltip?: React.ReactNode;
/**
* The field data test id for testing
*/
@ -60,6 +57,14 @@ type FieldWrapperProps = {
* Render line breaks in the description
*/
renderDescriptionLineBreaks?: boolean;
/**
* The field tooltip label
*/
tooltip?: React.ReactNode;
/**
* The link containing more information about the field
*/
knowMoreLink?: string;
};
export type FieldWrapperPassThroughProps = Omit<
@ -97,6 +102,7 @@ export const FieldWrapper = (props: FieldWrapperProps) => {
id,
labelIcon,
label,
knowMoreLink,
className,
size = 'full',
error,
@ -141,14 +147,15 @@ export const FieldWrapper = (props: FieldWrapperProps) => {
if (label) {
FieldLabel = () => (
<label htmlFor={id} className={clsx('block pt-1 text-muted mb-xs')}>
<span className={clsx('flex items-center font-semibold')}>
<span className={loading ? 'relative' : ''}>
<label htmlFor={id} className={clsx('block pt-1 text-gray-600 mb-xs')}>
<span className={clsx('flex items-center')}>
<span className={clsx('font-semibold', { relative: !!loading })}>
<FieldLabelIcon />
{label}
{loading ? <Skeleton className="absolute inset-0" /> : null}
</span>
{!loading && tooltip ? <IconTooltip message={tooltip} /> : null}
{!loading && !!knowMoreLink && <KnowMoreLink href={knowMoreLink} />}
</span>
<FieldDescription />
</label>

View File

@ -0,0 +1,44 @@
import type { ComponentPropsWithoutRef } from 'react';
import type { ComponentMeta, ComponentStory } from '@storybook/react';
import * as React from 'react';
import { KnowMoreLink } from './KnowMoreLink';
export default {
title: 'components/KnowMoreLink ⚛️',
component: KnowMoreLink,
parameters: {
docs: {
description: {
component: `Useful to link the docs of external resource containing more information`,
},
source: { type: 'code' },
},
},
} as ComponentMeta<typeof KnowMoreLink>;
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// DEFAULT STORY
// #region
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------------------
// --------------------------------------------------
// STORY DEFINITION
// --------------------------------------------------
export const Basic: ComponentStory<typeof KnowMoreLink> = args => (
<KnowMoreLink {...args} />
);
Basic.storyName = '🧰 Basic';
const basicArgs: ComponentPropsWithoutRef<typeof KnowMoreLink> = {
href: 'https://hasura.io/docs',
};
Basic.args = basicArgs;
// #endregion

View File

@ -0,0 +1,24 @@
import * as React from 'react';
interface KnowMoreLinkProps {
href: string;
}
/**
* The updated KnowMoreLink component.
* @see https://github.com/hasura/graphql-engine-mono/pull/7023
*/
export const KnowMoreLink: React.VFC<KnowMoreLinkProps> = props => {
const { href } = props;
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="ml-xs italic text-sm text-secondary"
>
(Know More)
</a>
);
};

View File

@ -0,0 +1 @@
export { KnowMoreLink } from './KnowMoreLink';

View File

@ -37,7 +37,6 @@ import {
INSECURE_TLS_ALLOW_LIST,
} from './helpers/versionUtils';
import AuthContainer from './components/Services/Auth/AuthContainer';
import { PrometheusSettings } from './features/Prometheus';
import { FeatureFlags } from './features/FeatureFlags';
import { AllowListDetail } from './components/Services/AllowList';
@ -158,7 +157,6 @@ const routes = store => {
{checkFeatureSupport(INSECURE_TLS_ALLOW_LIST) && (
<Route path="insecure-domain" component={InsecureDomains} />
)}
<Route path="prometheus-settings" component={PrometheusSettings} />
<Route path="feature-flags" component={FeatureFlags} />
</Route>
{dataRouter}

View File

@ -44,6 +44,7 @@ import {
isMonitoringTabSupportedEnvironment,
AllowListDetail,
PrometheusSettings,
OpenTelemetryFeature,
} from '@hasura/console-oss';
import AccessDeniedComponent from './components/AccessDenied/AccessDenied';
import { restrictedPathsMetadata } from './utils/redirectUtils';
@ -355,6 +356,7 @@ const routes = store => {
<Route path="inherited-roles" component={InheritedRolesContainer} />
<Route path="insecure-domain" component={InsecureDomains} />
<Route path="prometheus-settings" component={PrometheusSettings} />
<Route path="opentelemetry" component={OpenTelemetryFeature} />
<Route path="feature-flags" component={FeatureFlags} />
</Route>
{dataRouter}