From 98f293e229108e2d72d4116bcfb66ab801ba6981 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Mon, 6 Oct 2025 15:22:00 +0100 Subject: [PATCH] OpenFeature: Create basic frontend client + example usage (#110587) * initial basic OpenFeature client for datasource class * add dep * update, use a wrapping function to enforce types * move init OF to grafana-runtime * docs * Fix circular dependency causing tests to fail * codeowners * use toggle in datasourcewithbackend * Fix CUJs group-by test * Comments * update docs, make default value mandatory * revert using for queryServiceFromUI toggle --- .github/CODEOWNERS | 1 + contribute/feature-toggles.md | 25 +++++++++++- .../dashboard-cujs/group-by-cujs.spec.ts | 5 ++- package.json | 1 + packages/grafana-runtime/package.json | 3 ++ .../grafana-runtime/src/internal/index.ts | 2 + .../src/internal/openFeature/index.ts | 33 +++++++++++++++ public/app/app.ts | 13 ++++++ yarn.lock | 40 +++++++++++++++++++ 9 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 packages/grafana-runtime/src/internal/openFeature/index.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bf54aeed6b4..22ed0a32a5e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -629,6 +629,7 @@ /packages/grafana-runtime/rollup.config.ts @grafana/grafana-frontend-platform /packages/grafana-runtime/src/index.ts @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend /packages/grafana-runtime/src/internal/index.ts @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend +/packages/grafana-runtime/src/internal/openFeature @grafana/grafana-frontend-platform /packages/grafana-runtime/src/unstable.ts @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend /packages/grafana-runtime/tsconfig.build.json @grafana/grafana-frontend-platform /packages/grafana-runtime/tsconfig.json @grafana/grafana-frontend-platform diff --git a/contribute/feature-toggles.md b/contribute/feature-toggles.md index 8e81c9c0c11..1e6b1b1b885 100644 --- a/contribute/feature-toggles.md +++ b/contribute/feature-toggles.md @@ -14,7 +14,30 @@ Once your feature toggle is defined, you can then wrap your feature around a che Examples: - [Backend](https://github.com/grafana/grafana/blob/feb2b5878b3e3ec551d64872c35edec2a0187812/pkg/services/authn/clients/session.go#L57): Use the `IsEnabled` function and pass in your feature toggle. -- [Frontend](https://github.com/grafana/grafana/blob/feb2b5878b3e3ec551d64872c35edec2a0187812/public/app/features/search/service/folders.ts#L14): Check the config for your feature toggle. + +### 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: + +- 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. + +e.g. + +```ts +import { evaluateBooleanFlag } 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)) { + // do new things + } +} +``` ## Enabling toggles in development diff --git a/e2e-playwright/dashboard-cujs/group-by-cujs.spec.ts b/e2e-playwright/dashboard-cujs/group-by-cujs.spec.ts index 65e37aefbed..ad5008a0be1 100644 --- a/e2e-playwright/dashboard-cujs/group-by-cujs.spec.ts +++ b/e2e-playwright/dashboard-cujs/group-by-cujs.spec.ts @@ -71,9 +71,12 @@ test.describe( await test.step('3.Edit and restore default groupBy', async () => { const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); + // Wait for the page to load + const groupByVariable = getGroupByInput(dashboardPage, selectors); + await expect(groupByVariable).toBeVisible(); + const initialSelectedOptionsCount = await groupByValues.count(); - const groupByVariable = getGroupByInput(dashboardPage, selectors); await groupByVariable.click(); const groupByOption = groupByOptions.nth(1); diff --git a/package.json b/package.json index 3bece98c2ab..64006c8d73c 100644 --- a/package.json +++ b/package.json @@ -302,6 +302,7 @@ "@locker/near-membrane-shared-dom": "0.14.0", "@msagl/core": "^1.1.19", "@msagl/parser": "^1.1.19", + "@openfeature/web-sdk": "^1.6.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/exporter-collector": "0.25.0", "@opentelemetry/semantic-conventions": "1.37.0", diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index c4e32ce6340..f9ba7ce203d 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -58,6 +58,9 @@ "@grafana/faro-web-sdk": "^1.13.2", "@grafana/schema": "12.3.0-pre", "@grafana/ui": "12.3.0-pre", + "@openfeature/core": "^1.9.0", + "@openfeature/ofrep-web-provider": "^0.3.3", + "@openfeature/web-sdk": "^1.6.1", "@types/systemjs": "6.15.3", "history": "4.10.1", "lodash": "4.17.21", diff --git a/packages/grafana-runtime/src/internal/index.ts b/packages/grafana-runtime/src/internal/index.ts index de669f7af74..aed6b86ebfb 100644 --- a/packages/grafana-runtime/src/internal/index.ts +++ b/packages/grafana-runtime/src/internal/index.ts @@ -27,3 +27,5 @@ export { } from '../services/pluginExtensions/getObservablePluginLinks'; export { UserStorage } from '../utils/userStorage'; + +export { initOpenFeature, evaluateBooleanFlag } from './openFeature'; diff --git a/packages/grafana-runtime/src/internal/openFeature/index.ts b/packages/grafana-runtime/src/internal/openFeature/index.ts new file mode 100644 index 00000000000..891eefe7958 --- /dev/null +++ b/packages/grafana-runtime/src/internal/openFeature/index.ts @@ -0,0 +1,33 @@ +import { OFREPWebProvider } from '@openfeature/ofrep-web-provider'; +import { OpenFeature } from '@openfeature/web-sdk'; + +import { FeatureToggles } from '@grafana/data'; + +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 + */ + + const ofProvider = new OFREPWebProvider({ + baseUrl: '/apis/features.grafana.app/v0alpha1/namespaces/' + config.namespace, + pollInterval: -1, // disable polling + timeoutMs: 5_000, + }); + + await OpenFeature.setProviderAndWait(ofProvider, { + targetingKey: config.namespace, + namespace: config.namespace, + }); +} + +export function evaluateBooleanFlag(flagName: FeatureFlagName, defaultValue: boolean): boolean { + return OpenFeature.getClient().getBooleanValue(flagName, defaultValue); +} diff --git a/public/app/app.ts b/public/app/app.ts index 25c730a2d34..7896117c1e7 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -43,6 +43,7 @@ import { setMegaMenuOpenHook, } from '@grafana/runtime'; import { + initOpenFeature, setGetObservablePluginComponents, setGetObservablePluginLinks, setPanelDataErrorView, @@ -129,8 +130,20 @@ export class GrafanaApp { async init() { try { await preInitTasks(); + // Let iframe container know grafana has started loading window.parent.postMessage('GrafanaAppInit', '*'); + + // Currently the OpenFeature API requires a signed in user. This means feature flags cannot be used + // on the login page. + if (contextSrv.user.isSignedIn) { + try { + await initOpenFeature(); + } catch (err) { + console.error('Failed to initialize OpenFeature provider', err); + } + } + const regionalFormat = config.featureToggles.localeFormatPreference ? config.regionalFormat : contextSrv.user.language; diff --git a/yarn.lock b/yarn.lock index 3f6986db4f0..00317070cb3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3489,6 +3489,9 @@ __metadata: "@grafana/faro-web-sdk": "npm:^1.13.2" "@grafana/schema": "npm:12.3.0-pre" "@grafana/ui": "npm:12.3.0-pre" + "@openfeature/core": "npm:^1.9.0" + "@openfeature/ofrep-web-provider": "npm:^0.3.3" + "@openfeature/web-sdk": "npm:^1.6.1" "@rollup/plugin-node-resolve": "npm:16.0.1" "@rollup/plugin-terser": "npm:0.4.4" "@testing-library/dom": "npm:10.4.1" @@ -5752,6 +5755,42 @@ __metadata: languageName: node linkType: hard +"@openfeature/core@npm:^1.9.0": + version: 1.9.0 + resolution: "@openfeature/core@npm:1.9.0" + checksum: 10/c6d20edc09053afd99752fe46d8328158680950bca4b86679f67f79249d7226eea127b31fffdc38e26ecb729f2bab5a4a5a7c1db708ae76b7fbbac68cd56f094 + languageName: node + linkType: hard + +"@openfeature/ofrep-core@npm:^1.0.0": + version: 1.1.0 + resolution: "@openfeature/ofrep-core@npm:1.1.0" + peerDependencies: + "@openfeature/core": ^1.6.0 + checksum: 10/4198f2f1abf974822bf14530a7f514292d8235552d6e61465d29ffe42d092f675e7f56a9e9da5aa7d45dfbd98cc36316efbdaadaf83e8f510f35865612f5f24f + languageName: node + linkType: hard + +"@openfeature/ofrep-web-provider@npm:^0.3.3": + version: 0.3.3 + resolution: "@openfeature/ofrep-web-provider@npm:0.3.3" + dependencies: + "@openfeature/ofrep-core": "npm:^1.0.0" + peerDependencies: + "@openfeature/web-sdk": ^1.4.0 + checksum: 10/85f362e3ebaa9d421be91e4d966284e28850649e417d5db81181960b95ac693c9faa4a0bdc84eeb03fa694a8cd1e1f5d9de9bf0fba62f8e2669f9637836f0884 + languageName: node + linkType: hard + +"@openfeature/web-sdk@npm:^1.6.1": + version: 1.6.1 + resolution: "@openfeature/web-sdk@npm:1.6.1" + peerDependencies: + "@openfeature/core": ^1.9.0 + checksum: 10/8bd7d1ea386e21cdd7492cab2fd1d2b138b4e6a376a4c0a40244633e5955f6452039bc2633fc5230bd7b494506a4137ba7210d40850634f9618f77a0ee435f9d + languageName: node + linkType: hard + "@opentelemetry/api-logs@npm:0.202.0": version: 0.202.0 resolution: "@opentelemetry/api-logs@npm:0.202.0" @@ -18218,6 +18257,7 @@ __metadata: "@msagl/core": "npm:^1.1.19" "@msagl/parser": "npm:^1.1.19" "@npmcli/package-json": "npm:^6.0.0" + "@openfeature/web-sdk": "npm:^1.6.1" "@opentelemetry/api": "npm:1.9.0" "@opentelemetry/exporter-collector": "npm:0.25.0" "@opentelemetry/semantic-conventions": "npm:1.37.0"