OpenFeature: Refactor frontend usage (#117573)

* Refactor core OF implementation to use a domain, remove eval wrapper

* Add flag test utils

* Add the react provider to use the open feature hooks

* Update recentlyViewedDashboards flag usage with react hooks

* Update useMTPlugins usage with vanilla OF client

* Update faroSessionReplay usage with vanilla OF client

* Update datasources.querier.fe-allowed-types usage with vanilla OF client

* Update guideline documentation
Update contribute/feature-toggles.md

Co-authored-by: Laura Benz <48948963+L-M-K-B@users.noreply.github.com>

---------

Co-authored-by: Laura Benz <48948963+L-M-K-B@users.noreply.github.com>
This commit is contained in:
Josh Hunt 2026-02-12 09:03:53 +00:00 committed by GitHub
parent aee6c2cfb0
commit 5de0ffefee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 319 additions and 113 deletions

View file

@ -80,26 +80,73 @@ func TestFoo(t *testing.T) {
### Frontend
Use the new OpenFeature-based feature flag client for all new feature flags. There are some differences compared to the legacy `config.featureToggles` system:
Use the OpenFeature React hooks for all new feature flags. The React hooks automatically stay up to date with the latest flag values and integrate seamlessly with React components.
- Feature flag initialisation is async, but will be finished by the time the UI is rendered. This means you cannot get the value of a feature flag at the 'top level' of a module/file
- Call `evaluateBooleanFlag("flagName")` from `@grafana/runtime/internal` instead to get the value of a feature flag
- Feature flag values _may_ change over the lifetime of the session. Do not store the value in a variable that is used for longer than a single render - always call `evaluateBooleanFlag` lazily when you use the value.
#### Using React hooks (recommended)
e.g.
For React components, use the `useBooleanFlagValue` hook from `@openfeature/react-sdk`:
```tsx
import { useBooleanFlagValue } from '@openfeature/react-sdk';
function MyComponent() {
// The hook returns the current value and automatically updates when the flag changes
const isNewPreferencesEnabled = useBooleanFlagValue('newPreferences', false);
if (isNewPreferencesEnabled) {
return <NewPreferencesUI />;
}
return <LegacyPreferencesUI />;
}
```
Flag values _may_ change over the lifetime of the session, so do not store the result elsewhere in a way it will not react to changes in the flag value.
If using non-boolean flags (a unique feature of the new feature flag system), explore the other exports from `@openfeature/react-sdk` to see how to use them.
#### Using the client directly (non-React contexts)
For advanced, non-React contexts (utilities, class methods, callbacks), you can use the OpenFeature client directly.
However, because this is seperate from the React render loop there are important caveats you must be aware of:
- Flag values are loaded asynchronously, so you cannot call `getBooleanValue()` just at the top-level of a module. You must wait until `app.ts` has initialised until you call a flag otherwise you will only get the default value
- Flag values can change over the lifetime of the session, so do not store or cache the result. Always evaluate flags just in time when you use them, preferably in the if statement, for example.
It is strongly preferred to use the React hooks instead of getting the client.
```ts
import { evaluateBooleanFlag } from '@grafana/runtime/internal';
import { getFeatureFlagClient } from '@grafana/runtime/internal';
// BAD - Don't do this. The feature toggle will not evaluate correctly
const isEnabled = evaluateBooleanFlag('newPreferences', false);
function makeAPICall() {
// GOOD - The feature toggle should be called after app initialisation
if (evaluateBooleanFlag('newPreferences', false)) {
// GOOD - The feature toggle should be called after app initialisation
function doThing() {
if (getFeatureFlagClient().getBooleanValue('newPreferences', false)) {
// do new things
}
}
// BAD - Don't do this. The feature toggle must wait until app initialisation
const isEnabled = getFeatureFlagClient().getBooleanValue('newPreferences', false);
function doThing() {
if (isEnabled) {
// do new things
}
}
// BAD - Don't do this. The feature toggle will not change in response to updates
class FooSrv {
constructor() {
this.isEnabled = getFeatureFlagClient().getBooleanValue('newPreferences', false);
}
doThing() {
if (this.isEnabled) {
// do new things
}
}
}
```
## Enabling toggles in development

View file

@ -313,6 +313,7 @@
"@msagl/core": "^1.1.19",
"@msagl/parser": "^1.1.19",
"@openfeature/ofrep-web-provider": "^0.3.3",
"@openfeature/react-sdk": "^1.2.0",
"@openfeature/web-sdk": "^1.6.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/exporter-collector": "0.25.0",

View file

@ -60,7 +60,7 @@
"@grafana/ui": "12.4.0-pre",
"@openfeature/core": "^1.9.0",
"@openfeature/ofrep-web-provider": "^0.3.3",
"@openfeature/web-sdk": "^1.6.1",
"@openfeature/react-sdk": "^1.2.0",
"@types/systemjs": "6.15.3",
"history": "4.10.1",
"lodash": "^4.17.23",

View file

@ -28,7 +28,8 @@ export {
export { UserStorage } from '../utils/userStorage';
export { initOpenFeature, evaluateBooleanFlag } from './openFeature';
export { initOpenFeature, getFeatureFlagClient } from '../internal/openFeature';
export { getAppPluginMeta, getAppPluginMetas, setAppPluginMetas } from '../services/pluginMeta/apps';
export {
useAppPluginMeta,

View file

@ -1,5 +1,5 @@
import { OFREPWebProvider } from '@openfeature/ofrep-web-provider';
import { OpenFeature } from '@openfeature/web-sdk';
import { OpenFeature } from '@openfeature/react-sdk';
import { FeatureToggles } from '@grafana/data';
@ -7,15 +7,16 @@ import { config } from '../../config';
export type FeatureFlagName = keyof FeatureToggles;
export async function initOpenFeature() {
/**
* Note: Currently we don't have a way to override OpenFeature flags for tests or localStorage.
* A few improvements we could make:
* - When running in tests (unit or e2e?), we could use InMemoryProvider instead
* - Use Multi-Provider to combine InMemoryProvider (for localStorage) with OFREPWebProvider
* to allow for overrides https://github.com/open-feature/js-sdk-contrib/tree/main/libs/providers/multi-provider
*/
// The domain creates the unique instance of the OpenFeature client for Grafana core,
// with its own evaluation context and provider.
// Plugins should not use this client or domain, and instead create their own client
// with a different domain to avoid conflicts.
//
// If changing this, you MUST also update the same constant in packages/grafana-test-utils/src/utilities/featureFlags.ts
// to ensure tests work correctly.
export const GRAFANA_CORE_OPEN_FEATURE_DOMAIN = 'internal-grafana-core';
export async function initOpenFeature() {
const subPath = config.appSubUrl || '';
const baseUrl = `${subPath}/apis/features.grafana.app/v0alpha1/namespaces/${config.namespace}`;
@ -25,12 +26,18 @@ export async function initOpenFeature() {
timeoutMs: 5_000,
});
await OpenFeature.setProviderAndWait(ofProvider, {
await OpenFeature.setProviderAndWait(GRAFANA_CORE_OPEN_FEATURE_DOMAIN, ofProvider, {
targetingKey: config.namespace,
...config.openFeatureContext,
});
}
export function evaluateBooleanFlag(flagName: FeatureFlagName, defaultValue: boolean): boolean {
return OpenFeature.getClient().getBooleanValue(flagName, defaultValue);
/**
* Get the OpenFeature client for Grafana core.
* Prefer to instead use the React hooks for evaluating feature flags instead as they remain up to date with the latest flag values.
* If you must use this client directly, do not store the evaluation result for later - always call `getFeatureFlagClient().getFooValue()` just
* in time when you use it to ensure you get the latest value.
*/
export function getFeatureFlagClient() {
return OpenFeature.getClient(GRAFANA_CORE_OPEN_FEATURE_DOMAIN);
}

View file

@ -1,4 +1,4 @@
import { evaluateBooleanFlag } from '../../internal/openFeature';
import { setTestFlags } from '@grafana/test-utils/unstable';
import {
getAppPluginMeta,
@ -11,20 +11,22 @@ import { initPluginMetas } from './plugins';
import { app } from './test-fixtures/config.apps';
jest.mock('./plugins', () => ({ ...jest.requireActual('./plugins'), initPluginMetas: jest.fn() }));
jest.mock('../../internal/openFeature', () => ({
...jest.requireActual('../../internal/openFeature'),
evaluateBooleanFlag: jest.fn(),
}));
const initPluginMetasMock = jest.mocked(initPluginMetas);
const evaluateBooleanFlagMock = jest.mocked(evaluateBooleanFlag);
describe('when useMTPlugins flag is enabled and apps is not initialized', () => {
beforeAll(() => {
setTestFlags({ useMTPlugins: true });
});
afterAll(() => {
setTestFlags({});
});
beforeEach(() => {
setAppPluginMetas({});
jest.resetAllMocks();
initPluginMetasMock.mockResolvedValue({ items: [] });
evaluateBooleanFlagMock.mockReturnValue(true);
});
it('getAppPluginMetas should call initPluginMetas and return correct result', async () => {
@ -57,10 +59,17 @@ describe('when useMTPlugins flag is enabled and apps is not initialized', () =>
});
describe('when useMTPlugins flag is enabled and apps is initialized', () => {
beforeAll(() => {
setTestFlags({ useMTPlugins: true });
});
afterAll(() => {
setTestFlags({});
});
beforeEach(() => {
setAppPluginMetas({ 'myorg-someplugin-app': app });
jest.resetAllMocks();
evaluateBooleanFlagMock.mockReturnValue(true);
});
it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => {
@ -111,10 +120,17 @@ describe('when useMTPlugins flag is enabled and apps is initialized', () => {
});
describe('when useMTPlugins flag is disabled and apps is not initialized', () => {
beforeAll(() => {
setTestFlags({ useMTPlugins: false });
});
afterAll(() => {
setTestFlags({});
});
beforeEach(() => {
setAppPluginMetas({});
jest.resetAllMocks();
evaluateBooleanFlagMock.mockReturnValue(false);
});
it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => {
@ -147,10 +163,17 @@ describe('when useMTPlugins flag is disabled and apps is not initialized', () =>
});
describe('when useMTPlugins flag is disabled and apps is initialized', () => {
beforeAll(() => {
setTestFlags({ useMTPlugins: false });
});
afterAll(() => {
setTestFlags({});
});
beforeEach(() => {
setAppPluginMetas({ 'myorg-someplugin-app': app });
jest.resetAllMocks();
evaluateBooleanFlagMock.mockReturnValue(false);
});
it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => {
@ -201,10 +224,17 @@ describe('when useMTPlugins flag is disabled and apps is initialized', () => {
});
describe('immutability', () => {
beforeAll(() => {
setTestFlags({ useMTPlugins: false });
});
afterAll(() => {
setTestFlags({});
});
beforeEach(() => {
setAppPluginMetas({ 'myorg-someplugin-app': app });
jest.resetAllMocks();
evaluateBooleanFlagMock.mockReturnValue(false);
});
it('getAppPluginMetas should return a deep clone', async () => {

View file

@ -1,7 +1,7 @@
import type { AppPluginConfig } from '@grafana/data';
import { config } from '../../config';
import { evaluateBooleanFlag } from '../../internal/openFeature';
import { getFeatureFlagClient } from '../../internal/openFeature';
import { getAppPluginMapper } from './mappers/mappers';
import { initPluginMetas } from './plugins';
@ -14,7 +14,7 @@ function initialized(): boolean {
}
async function initAppPluginMetas(): Promise<void> {
if (!evaluateBooleanFlag('useMTPlugins', false)) {
if (!getFeatureFlagClient().getBooleanValue('useMTPlugins', false)) {
// eslint-disable-next-line no-restricted-syntax
apps = config.apps;
return;

View file

@ -1,4 +1,4 @@
import { evaluateBooleanFlag } from '../../internal/openFeature';
import { setTestFlags } from '@grafana/test-utils/unstable';
import {
getListedPanelPluginIds,
@ -12,20 +12,22 @@ import { initPluginMetas } from './plugins';
import { panel } from './test-fixtures/config.panels';
jest.mock('./plugins', () => ({ ...jest.requireActual('./plugins'), initPluginMetas: jest.fn() }));
jest.mock('../../internal/openFeature', () => ({
...jest.requireActual('../../internal/openFeature'),
evaluateBooleanFlag: jest.fn(),
}));
const initPluginMetasMock = jest.mocked(initPluginMetas);
const evaluateBooleanFlagMock = jest.mocked(evaluateBooleanFlag);
describe('when useMTPlugins flag is enabled and panels is not initialized', () => {
beforeAll(() => {
setTestFlags({ useMTPlugins: true });
});
afterAll(() => {
setTestFlags({});
});
beforeEach(() => {
setPanelPluginMetas({});
jest.resetAllMocks();
initPluginMetasMock.mockResolvedValue({ items: [] });
evaluateBooleanFlagMock.mockReturnValue(true);
});
it('getPanelPluginMetas should call initPluginMetas and return correct result', async () => {
@ -65,10 +67,17 @@ describe('when useMTPlugins flag is enabled and panels is not initialized', () =
});
describe('when useMTPlugins flag is enabled and panels is initialized', () => {
beforeAll(() => {
setTestFlags({ useMTPlugins: true });
});
afterAll(() => {
setTestFlags({});
});
beforeEach(() => {
setPanelPluginMetas({ 'grafana-test-panel': panel });
jest.resetAllMocks();
evaluateBooleanFlagMock.mockReturnValue(true);
});
it('getPanelPluginMetas should not call initPluginMetas and return correct result', async () => {
@ -126,10 +135,17 @@ describe('when useMTPlugins flag is enabled and panels is initialized', () => {
});
describe('when useMTPlugins flag is disabled and panels is not initialized', () => {
beforeAll(() => {
setTestFlags({ useMTPlugins: false });
});
afterAll(() => {
setTestFlags({});
});
beforeEach(() => {
setPanelPluginMetas({});
jest.resetAllMocks();
evaluateBooleanFlagMock.mockReturnValue(false);
});
it('getPanelPluginMetas should not call initPluginMetas and return correct result', async () => {
@ -169,10 +185,17 @@ describe('when useMTPlugins flag is disabled and panels is not initialized', ()
});
describe('when useMTPlugins flag is disabled and panels is initialized', () => {
beforeAll(() => {
setTestFlags({ useMTPlugins: false });
});
afterAll(() => {
setTestFlags({});
});
beforeEach(() => {
setPanelPluginMetas({ 'grafana-test-panel': panel });
jest.resetAllMocks();
evaluateBooleanFlagMock.mockReturnValue(false);
});
it('getPanelPluginMetas should not call initPluginMetas and return correct result', async () => {
@ -230,10 +253,17 @@ describe('when useMTPlugins flag is disabled and panels is initialized', () => {
});
describe('immutability', () => {
beforeAll(() => {
setTestFlags({ useMTPlugins: false });
});
afterAll(() => {
setTestFlags({});
});
beforeEach(() => {
setPanelPluginMetas({ 'grafana-test-panel': panel });
jest.resetAllMocks();
evaluateBooleanFlagMock.mockReturnValue(false);
});
it('getPanelPluginMetas should return a deep clone', async () => {

View file

@ -1,7 +1,7 @@
import type { PanelPluginMeta } from '@grafana/data';
import { config } from '../../config';
import { evaluateBooleanFlag } from '../../internal/openFeature';
import { getFeatureFlagClient } from '../../internal/openFeature';
import { getPanelPluginMapper } from './mappers/mappers';
import { initPluginMetas } from './plugins';
@ -14,7 +14,7 @@ function initialized(): boolean {
}
async function initPanelPluginMetas(): Promise<void> {
if (!evaluateBooleanFlag('useMTPlugins', false)) {
if (!getFeatureFlagClient().getBooleanValue('useMTPlugins', false)) {
// eslint-disable-next-line no-restricted-syntax
panels = config.panels;
return;

View file

@ -1,17 +1,11 @@
import { evaluateBooleanFlag } from '../../internal/openFeature';
import { setTestFlags } from '@grafana/test-utils/unstable';
import { invalidateCache, setLogger } from '../../utils/getCachedPromise';
import { type MonitoringLogger } from '../../utils/logging';
import { initPluginMetas } from './plugins';
import { v0alpha1Meta } from './test-fixtures/v0alpha1Response';
jest.mock('../../internal/openFeature', () => ({
...jest.requireActual('../../internal/openFeature'),
evaluateBooleanFlag: jest.fn(),
}));
const evaluateBooleanFlagMock = jest.mocked(evaluateBooleanFlag);
const originalFetch = global.fetch;
let loggerMock: MonitoringLogger;
@ -33,8 +27,12 @@ afterEach(() => {
});
describe('when useMTPlugins toggle is enabled and cache is not initialized', () => {
beforeEach(() => {
evaluateBooleanFlagMock.mockReturnValue(true);
beforeAll(() => {
setTestFlags({ useMTPlugins: true });
});
afterAll(() => {
setTestFlags({});
});
it('initPluginMetas should call loadPluginMetas and return correct result if response is ok', async () => {
@ -54,8 +52,12 @@ describe('when useMTPlugins toggle is enabled and cache is not initialized', ()
});
describe('when useMTPlugins toggle is enabled and errors occur', () => {
beforeEach(() => {
evaluateBooleanFlagMock.mockReturnValue(true);
beforeAll(() => {
setTestFlags({ useMTPlugins: true });
});
afterAll(() => {
setTestFlags({});
});
it('initPluginMetas should log when fetch fails', async () => {
@ -114,7 +116,14 @@ describe('when useMTPlugins toggle is enabled and errors occur', () => {
describe('when useMTPlugins toggle is disabled and cache is not initialized', () => {
beforeEach(() => {
global.fetch = jest.fn();
evaluateBooleanFlagMock.mockReturnValue(false);
});
beforeAll(() => {
setTestFlags({ useMTPlugins: false });
});
afterAll(() => {
setTestFlags({});
});
it('initPluginMetas should call loadPluginMetas and return correct result if response is ok', async () => {
@ -128,7 +137,14 @@ describe('when useMTPlugins toggle is disabled and cache is not initialized', ()
describe('when useMTPlugins toggle is disabled and cache is initialized', () => {
beforeEach(() => {
global.fetch = jest.fn();
evaluateBooleanFlagMock.mockReturnValue(false);
});
beforeAll(() => {
setTestFlags({ useMTPlugins: false });
});
afterAll(() => {
setTestFlags({});
});
it('initPluginMetas should return cache', async () => {

View file

@ -1,5 +1,5 @@
import { config } from '../../config';
import { evaluateBooleanFlag } from '../../internal/openFeature';
import { getFeatureFlagClient } from '../../internal/openFeature';
import { getCachedPromise } from '../../utils/getCachedPromise';
import type { PluginMetasResponse } from './types';
@ -9,7 +9,7 @@ function getApiVersion(): string {
}
async function loadPluginMetas(): Promise<PluginMetasResponse> {
if (!evaluateBooleanFlag('useMTPlugins', false)) {
if (!getFeatureFlagClient().getBooleanValue('useMTPlugins', false)) {
const result = { items: [] };
return result;
}

View file

@ -1,4 +1,3 @@
import { OpenFeature } from '@openfeature/web-sdk';
import { lastValueFrom, merge, Observable, of } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
@ -22,6 +21,7 @@ import {
import { reportInteraction } from '../analytics/utils';
import { config } from '../config';
import { getFeatureFlagClient } from '../internal/openFeature';
import {
BackendSrvRequest,
FetchResponse,
@ -215,7 +215,7 @@ class DataSourceWithBackend<
// Use the new query service
if (config.featureToggles.queryServiceFromUI) {
const allowedTypes = OpenFeature.getClient().getObjectValue('datasources.querier.fe-allowed-types', {
const allowedTypes = getFeatureFlagClient().getObjectValue('datasources.querier.fe-allowed-types', {
types: [],
});
if (isQueryServiceCompatible(datasources, allowedTypes)) {

View file

@ -53,6 +53,7 @@
"test:ci": "jest --maxWorkers 4"
},
"dependencies": {
"@openfeature/react-sdk": "^1.2.0",
"chance": "^1.1.13",
"jest-matcher-utils": "29.7.0",
"lodash": "^4.17.23",

View file

@ -11,3 +11,5 @@ export {
} from './fixtures/scopes';
export { default as allHandlers } from './handlers/all-handlers';
export { default as scopeHandlers } from './handlers/apis/scope.grafana.app/v0alpha1/handlers';
export { setTestFlags, getTestFeatureFlagClient } from './utilities/featureFlags';

View file

@ -0,0 +1,49 @@
import { InMemoryProvider, JsonValue, OpenFeature } from '@openfeature/react-sdk';
let ofProvider: InMemoryProvider;
// If changing this, you MUST also update the same constant in packages/grafana-runtime/src/internal/openFeature/constants.ts
const GRAFANA_CORE_OPEN_FEATURE_DOMAIN = 'internal-grafana-core';
function initTestOpenFeatureClient(): void {
ofProvider ??= new InMemoryProvider();
OpenFeature.setProvider(GRAFANA_CORE_OPEN_FEATURE_DOMAIN, ofProvider);
}
/**
* Returns an OpenFeature client configured for use in tests. Not intended for general use im tests - prefer setting mock
* flag values via `setTestFlags` instead.
*/
export function getTestFeatureFlagClient() {
if (!ofProvider) {
initTestOpenFeatureClient();
}
return OpenFeature.getClient(GRAFANA_CORE_OPEN_FEATURE_DOMAIN);
}
type FlagSet = Record<string, boolean | string | number | JsonValue>;
/**
* Sets OpenFeature flag values for tests. Call it with an object of flag names and their values.
* This modifies the global environment, so be sure to reset flags in `after` / `afterEach`.
*/
export function setTestFlags(flags: FlagSet = {}) {
if (!ofProvider) {
initTestOpenFeatureClient();
}
const flagConfig: Parameters<typeof ofProvider.putConfiguration>[0] = {};
for (const [flagName, value] of Object.entries(flags)) {
flagConfig[flagName] = {
variants: {
testedVariant: value,
},
defaultVariant: 'testedVariant',
disabled: false,
};
}
ofProvider.putConfiguration(flagConfig);
}

View file

@ -1,3 +1,4 @@
import { OpenFeatureProvider } from '@openfeature/react-sdk';
import { UNSAFE_PortalProvider } from '@react-aria/overlays';
import { Action, KBarProvider } from 'kbar';
import { Component, ComponentType, Fragment, ReactNode } from 'react';
@ -6,6 +7,7 @@ import { Provider } from 'react-redux';
import { Route, Routes } from 'react-router-dom-v5-compat';
import { config, navigationLogger, reportInteraction } from '@grafana/runtime';
import { getFeatureFlagClient } from '@grafana/runtime/internal';
import { ErrorBoundaryAlert, getPortalContainer, GlobalStyles, PortalContainer, TimeRangeProvider } from '@grafana/ui';
import { getAppRoutes } from 'app/routes/routes';
import { store } from 'app/store/store';
@ -119,33 +121,35 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
return (
<Provider store={store}>
<ErrorBoundaryAlert boundaryName="app-wrapper" style="page">
<GrafanaContext.Provider value={context}>
<ThemeProvider value={config.theme2}>
<CacheProvider name={this.iconCacheID}>
<KBarProvider
actions={[]}
options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }}
>
<MaybeTimeRangeProvider>
<ScopesContextProvider>
<ExtensionRegistriesProvider registries={registries}>
<ExtensionSidebarContextProvider>
<UNSAFE_PortalProvider getContainer={getPortalContainer}>
<GlobalStyles />
<div className="grafana-app">
<RouterWrapper {...routerWrapperProps} />
<LiveConnectionWarning />
<PortalContainer />
</div>
</UNSAFE_PortalProvider>
</ExtensionSidebarContextProvider>
</ExtensionRegistriesProvider>
</ScopesContextProvider>
</MaybeTimeRangeProvider>
</KBarProvider>
</CacheProvider>
</ThemeProvider>
</GrafanaContext.Provider>
<OpenFeatureProvider client={getFeatureFlagClient()}>
<GrafanaContext.Provider value={context}>
<ThemeProvider value={config.theme2}>
<CacheProvider name={this.iconCacheID}>
<KBarProvider
actions={[]}
options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }}
>
<MaybeTimeRangeProvider>
<ScopesContextProvider>
<ExtensionRegistriesProvider registries={registries}>
<ExtensionSidebarContextProvider>
<UNSAFE_PortalProvider getContainer={getPortalContainer}>
<GlobalStyles />
<div className="grafana-app">
<RouterWrapper {...routerWrapperProps} />
<LiveConnectionWarning />
<PortalContainer />
</div>
</UNSAFE_PortalProvider>
</ExtensionSidebarContextProvider>
</ExtensionRegistriesProvider>
</ScopesContextProvider>
</MaybeTimeRangeProvider>
</KBarProvider>
</CacheProvider>
</ThemeProvider>
</GrafanaContext.Provider>
</OpenFeatureProvider>
</ErrorBoundaryAlert>
</Provider>
);

View file

@ -10,7 +10,7 @@ import {
} from '@grafana/faro-web-sdk';
import { TracingInstrumentation } from '@grafana/faro-web-tracing';
import { EchoBackend, EchoEvent, EchoEventType } from '@grafana/runtime';
import { evaluateBooleanFlag } from '@grafana/runtime/internal';
import { getFeatureFlagClient } from '@grafana/runtime/internal';
import { EchoSrvTransport } from './EchoSrvTransport';
import { beforeSendHandler } from './beforeSendHandler';
@ -49,7 +49,7 @@ export class GrafanaJavascriptAgentBackend
instrumentations.push(new TracingInstrumentation());
}
if (evaluateBooleanFlag('faroSessionReplay', false)) {
if (getFeatureFlagClient().getBooleanValue('faroSessionReplay', false)) {
instrumentations.push(
new ReplayInstrumentation({
maskAllInputs: true,

View file

@ -1,11 +1,11 @@
import { css } from '@emotion/css';
import { useBooleanFlagValue } from '@openfeature/react-sdk';
import { memo, useEffect, useMemo, useRef } from 'react';
import { useLocation, useParams } from 'react-router-dom-v5-compat';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { evaluateBooleanFlag } from '@grafana/runtime/internal';
import { FilterInput, useStyles2, Text, Stack } from '@grafana/ui';
import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks';
import { Page } from 'app/core/components/Page/Page';
@ -41,7 +41,9 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
const location = useLocation();
const search = useMemo(() => new URLSearchParams(location.search), [location.search]);
const { isReadOnlyRepo } = useGetResourceRepositoryView({ folderName: folderUID });
const isRecentlyViewedEnabled = !folderUID && evaluateBooleanFlag('recentlyViewedDashboards', false);
const isRecentlyViewedEnabledValue = useBooleanFlagValue('recentlyViewedDashboards', false);
const isExperimentRecentlyViewedDashboards = useBooleanFlagValue('experimentRecentlyViewedDashboards', false);
const isRecentlyViewedEnabled = !folderUID && isRecentlyViewedEnabledValue;
useEffect(() => {
stateManager.initStateFromUrl(folderUID);
@ -80,13 +82,13 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
}
hasEmittedExposureEvent.current = true;
const isExperimentTreatment = evaluateBooleanFlag('experimentRecentlyViewedDashboards', false);
const isExperimentTreatment = isExperimentRecentlyViewedDashboards;
reportInteraction('dashboards_browse_list_viewed', {
experiment_dashboard_list_recently_viewed: isExperimentTreatment ? 'treatment' : 'control',
has_recently_viewed_component: isExperimentTreatment,
});
}, [isRecentlyViewedEnabled]);
}, [isRecentlyViewedEnabled, isExperimentRecentlyViewedDashboards]);
const { data: folderDTO } = useGetFolderQueryFacade(folderUID);
const [saveFolder] = useUpdateFolder();

View file

@ -1,3 +1,4 @@
import { OpenFeatureProvider } from '@openfeature/react-sdk';
import { Store } from '@reduxjs/toolkit';
import { render, RenderOptions } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@ -18,6 +19,7 @@ import {
setChromeHeaderHeightHook,
setLocationService,
} from '@grafana/runtime';
import { getTestFeatureFlagClient } from '@grafana/test-utils/unstable';
import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext';
import { ModalsContextProvider } from 'app/core/context/ModalsContextProvider';
import { configureStore } from 'app/store/configureStore';
@ -86,15 +88,17 @@ const getWrapper = ({
return function Wrapper({ children }: PropsWithChildren) {
return (
<Provider store={reduxStore}>
<GrafanaContext.Provider value={context}>
<PotentialRouter>
<LocationServiceProvider service={locationService}>
<PotentialCompatRouter>
<ModalsContextProvider>{children}</ModalsContextProvider>
</PotentialCompatRouter>
</LocationServiceProvider>
</PotentialRouter>
</GrafanaContext.Provider>
<OpenFeatureProvider client={getTestFeatureFlagClient()}>
<GrafanaContext.Provider value={context}>
<PotentialRouter>
<LocationServiceProvider service={locationService}>
<PotentialCompatRouter>
<ModalsContextProvider>{children}</ModalsContextProvider>
</PotentialCompatRouter>
</LocationServiceProvider>
</PotentialRouter>
</GrafanaContext.Provider>
</OpenFeatureProvider>
</Provider>
);
};

View file

@ -3913,7 +3913,7 @@ __metadata:
"@grafana/ui": "npm:12.4.0-pre"
"@openfeature/core": "npm:^1.9.0"
"@openfeature/ofrep-web-provider": "npm:^0.3.3"
"@openfeature/web-sdk": "npm:^1.6.1"
"@openfeature/react-sdk": "npm:^1.2.0"
"@rollup/plugin-node-resolve": "npm:16.0.1"
"@rollup/plugin-terser": "npm:0.4.4"
"@testing-library/dom": "npm:10.4.1"
@ -4084,6 +4084,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@grafana/test-utils@workspace:packages/grafana-test-utils"
dependencies:
"@openfeature/react-sdk": "npm:^1.2.0"
"@swc/core": "npm:1.13.3"
"@swc/jest": "npm:^0.2.26"
"@types/chance": "npm:^1.1.7"
@ -6566,6 +6567,16 @@ __metadata:
languageName: node
linkType: hard
"@openfeature/react-sdk@npm:^1.2.0":
version: 1.2.0
resolution: "@openfeature/react-sdk@npm:1.2.0"
peerDependencies:
"@openfeature/web-sdk": ^1.5.0
react: ">=16.8.0"
checksum: 10/56411b9e6fbf79e13b2cbe2f45a632ba933127cad9b0e59bc714646d95516b51173e9aadd51fb829f27aba1f29383f2a5cd4d3cd97ae49a673e3175453e30c6e
languageName: node
linkType: hard
"@openfeature/web-sdk@npm:^1.6.1":
version: 1.7.2
resolution: "@openfeature/web-sdk@npm:1.7.2"
@ -20685,6 +20696,7 @@ __metadata:
"@msagl/parser": "npm:^1.1.19"
"@npmcli/package-json": "npm:^6.0.0"
"@openfeature/ofrep-web-provider": "npm:^0.3.3"
"@openfeature/react-sdk": "npm:^1.2.0"
"@openfeature/web-sdk": "npm:^1.6.1"
"@opentelemetry/api": "npm:1.9.0"
"@opentelemetry/exporter-collector": "npm:0.25.0"