Skip to content

Commit 44c11da

Browse files
authored
[Core] Expose isFeatureAvailable method from pricing API server setup (elastic#223525)
## 📓 Summary This work exposes an asynchronous `coreSetup.pricing.isFeatureAvailable()` method to assert whether Kibana is running on a given pricing product during a plugin server setup lifecycle step. ## Why do we need it? Some feature registrations are synchronous in the plugin setup, and until all the plugins have registered their features, knowing features availability at the server setup step is not possible. ## How does this fix it? To guarantee we have access to all the registered features during the setup lifecycle, the process works so that: 1. All the plugins are set up and the product features are registered synchronously. 2. Once all the plugins are set, the core pricing service emits a signal. 3. The `isFeatureAvailable` method on setup, if invoked previously, resolves asynchronously, reacting to the emitted signal. This guarantees access to the product feature model anywhere in Kibana, without having to rely on the raw tier configuration values. ```ts public setup(core: CoreSetup) { core.pricing.registerProductFeatures([ { id: 'my-plugin:feature1', description: 'A feature for observability products', products: [ { name: 'observability', tier: 'complete' }, ], } ]); core.pricing.isFeatureAvailable('my-plugin:feature1').then((isActiveObservabilityComplete) => { if (isActiveObservabilityComplete) { // Enable feature1 } }); } ```
1 parent 99f58ac commit 44c11da

8 files changed

Lines changed: 130 additions & 28 deletions

File tree

src/core/packages/plugins/server-internal/src/plugin_context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>({
293293
onStart: (...dependencyNames) => runtimeResolver.onStart(plugin.name, dependencyNames),
294294
},
295295
pricing: {
296+
isFeatureAvailable: deps.pricing.isFeatureAvailable,
296297
registerProductFeatures: deps.pricing.registerProductFeatures,
297298
},
298299
security: {

src/core/packages/pricing/common/src/pricing_tiers_client.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,17 @@ export class PricingTiersClient implements IPricingTiersClient {
2828
* @param productFeaturesRegistry - Registry containing the available product features
2929
*/
3030
constructor(
31-
private readonly tiers: TiersConfig,
31+
private tiers: TiersConfig,
3232
private readonly productFeaturesRegistry: ProductFeaturesRegistry
3333
) {}
3434

3535
/**
36-
* Checks if a product is active in the current pricing tier configuration.
36+
* Sets the pricing tiers configuration.
3737
*
38-
* @param product - The product to check
39-
* @returns True if the product is active, false otherwise
40-
* @internal
38+
* @param tiers - The new pricing tiers configuration
4139
*/
42-
private isActiveProduct = (product: PricingProduct) => {
43-
return Boolean(this.tiers.products?.some((currentProduct) => isEqual(currentProduct, product)));
40+
setTiers = (tiers: TiersConfig) => {
41+
this.tiers = tiers;
4442
};
4543

4644
/**
@@ -53,6 +51,17 @@ export class PricingTiersClient implements IPricingTiersClient {
5351
return this.tiers.enabled;
5452
};
5553

54+
/**
55+
* Checks if a product is active in the current pricing tier configuration.
56+
*
57+
* @param product - The product to check
58+
* @returns True if the product is active, false otherwise
59+
* @internal
60+
*/
61+
private isActiveProduct = (product: PricingProduct) => {
62+
return Boolean(this.tiers.products?.some((currentProduct) => isEqual(currentProduct, product)));
63+
};
64+
5665
/**
5766
* Determines if a feature is available based on the current pricing tier configuration.
5867
* When pricing tiers are disabled, all features are considered available.

src/core/packages/pricing/server-internal/README.md

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,20 +59,12 @@ Here's an example of how to consume the pricing service in a plugin:
5959
```typescript
6060
// my-plugin/server/plugin.ts
6161
import { CoreSetup, CoreStart, Plugin } from '@kbn/core/server';
62-
import { PricingServiceSetup, PricingServiceStart } from '@kbn/core-pricing-server';
63-
64-
interface MyPluginSetupDeps {
65-
pricing: PricingServiceSetup;
66-
}
67-
68-
interface MyPluginStartDeps {
69-
pricing: PricingServiceStart;
70-
}
7162

7263
export class MyPlugin implements Plugin {
73-
public setup(core: CoreSetup, { pricing }: MyPluginSetupDeps) {
64+
public setup(core: CoreSetup) {
7465
// Register features that your plugin provides
75-
pricing.registerProductFeatures([
66+
67+
core.pricing.registerProductFeatures([
7668
{
7769
id: 'my-plugin:feature1',
7870
description: 'A feature for observability products',
@@ -88,12 +80,22 @@ export class MyPlugin implements Plugin {
8880
],
8981
},
9082
]);
83+
84+
/**
85+
* Checks if a specific feature is available in the current pricing tier configuration.
86+
* Resolves asynchronously after the pricing service has been set up and all the plugins have registered their features.
87+
*/
88+
core.pricing.isFeatureAvailable('my-plugin:feature1').then((isActiveObservabilityComplete) => {
89+
if (isActiveObservabilityComplete) {
90+
// Enable feature1
91+
}
92+
});
9193
}
9294

93-
public start(core: CoreStart, { pricing }: MyPluginStartDeps) {
95+
public start(core: CoreStart) {
9496
// Check if a feature is available based on the current pricing tier
95-
const isFeature1Available = pricing.isFeatureAvailable('my-plugin:feature1');
96-
const isFeature2Available = pricing.isFeatureAvailable('my-plugin:feature2');
97+
const isFeature1Available = core.pricing.isFeatureAvailable('my-plugin:feature1');
98+
const isFeature2Available = core.pricing.isFeatureAvailable('my-plugin:feature2');
9799

98100
// Conditionally enable features based on availability
99101
if (isFeature1Available) {

src/core/packages/pricing/server-internal/src/pricing_service.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,56 @@ describe('PricingService', () => {
106106
expect(registry.get('feature1')).toBeDefined();
107107
expect(registry.get('feature2')).toBeDefined();
108108
});
109+
110+
it('allows checking if a feature is available', async () => {
111+
await service.preboot({ http: prebootHttp });
112+
const setup = await service.setup({ http: setupHttp });
113+
114+
const mockFeatures: PricingProductFeature[] = [
115+
{
116+
id: 'feature1',
117+
description: 'A feature',
118+
products: [{ name: 'observability', tier: 'complete' }],
119+
},
120+
];
121+
122+
setup.registerProductFeatures(mockFeatures);
123+
await setup.evaluateProductFeatures();
124+
125+
const isActiveObservabilityComplete = await setup.isFeatureAvailable('feature1');
126+
expect(isActiveObservabilityComplete).toBe(true);
127+
});
128+
});
129+
130+
it('should block isFeatureAvailable until evaluateProductFeatures is called', async () => {
131+
await service.preboot({ http: prebootHttp });
132+
const setup = await service.setup({ http: setupHttp });
133+
134+
// Start calling isFeatureAvailable, before said feature was registered or evaluateProductFeatures is called
135+
let resolved = false;
136+
const promise = setup.isFeatureAvailable('testFeature').then(() => {
137+
resolved = true;
138+
});
139+
140+
// Now register the feature
141+
setup.registerProductFeatures([
142+
{
143+
id: 'testFeature',
144+
description: 'Test Feature',
145+
products: [{ name: 'observability', tier: 'complete' }],
146+
},
147+
]);
148+
149+
// Wait a short bit to ensure it doesn't resolve prematurely
150+
await new Promise((r) => setTimeout(r, 100));
151+
expect(resolved).toBe(false);
152+
153+
// Now "unlock" the gate
154+
await setup.evaluateProductFeatures();
155+
156+
// Now it should resolve
157+
await promise;
158+
expect(resolved).toBe(true);
109159
});
110160

111161
describe('#start()', () => {

src/core/packages/pricing/server-internal/src/pricing_service.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import type { CoreContext } from '@kbn/core-base-server-internal';
1111
import type { Logger } from '@kbn/logging';
12-
import { firstValueFrom } from 'rxjs';
12+
import { Subject, firstValueFrom } from 'rxjs';
1313
import type { IConfigService } from '@kbn/config';
1414
import {
1515
type PricingProductFeature,
@@ -36,13 +36,22 @@ export class PricingService {
3636
private readonly configService: IConfigService;
3737
private readonly logger: Logger;
3838
private readonly productFeaturesRegistry: ProductFeaturesRegistry;
39+
40+
private readonly isEvaluated$ = new Subject<void>();
41+
private readonly isEvaluatedPromise = firstValueFrom(this.isEvaluated$);
42+
3943
private pricingConfig: PricingConfigType;
44+
private tiersClient: PricingTiersClient;
4045

4146
constructor(core: CoreContext) {
4247
this.logger = core.logger.get('pricing-service');
4348
this.configService = core.configService;
4449
this.productFeaturesRegistry = new ProductFeaturesRegistry();
4550
this.pricingConfig = { tiers: { enabled: false, products: [] } };
51+
this.tiersClient = new PricingTiersClient(
52+
this.pricingConfig.tiers,
53+
this.productFeaturesRegistry
54+
);
4655
}
4756

4857
public preboot({ http }: PrebootDeps) {
@@ -64,12 +73,27 @@ export class PricingService {
6473
this.configService.atPath<PricingConfigType>('pricing')
6574
);
6675

76+
this.tiersClient.setTiers(this.pricingConfig.tiers);
77+
6778
registerRoutes(http.createRouter(''), {
6879
pricingConfig: this.pricingConfig,
6980
productFeaturesRegistry: this.productFeaturesRegistry,
7081
});
7182

7283
return {
84+
/**
85+
* Evaluates the product features and emits the `isEvaluated$` signal.
86+
* This should be called after all plugins have registered their features.
87+
*/
88+
evaluateProductFeatures: () => this.isEvaluated$.next(),
89+
/**
90+
* Checks if a specific feature is available in the current pricing tier configuration.
91+
* Resolves asynchronously after the pricing service has been set up and all the plugins have registered their features.
92+
*/
93+
isFeatureAvailable: async (featureId: string) => {
94+
await this.isEvaluatedPromise;
95+
return this.tiersClient.isFeatureAvailable(featureId);
96+
},
7397
registerProductFeatures: (features: PricingProductFeature[]) => {
7498
features.forEach((feature) => {
7599
this.productFeaturesRegistry.register(feature);
@@ -79,13 +103,8 @@ export class PricingService {
79103
}
80104

81105
public start() {
82-
const tiersClient = new PricingTiersClient(
83-
this.pricingConfig.tiers,
84-
this.productFeaturesRegistry
85-
);
86-
87106
return {
88-
isFeatureAvailable: tiersClient.isFeatureAvailable,
107+
isFeatureAvailable: this.tiersClient.isFeatureAvailable,
89108
};
90109
}
91110
}

src/core/packages/pricing/server-mocks/src/pricing_service.mock.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { PricingService } from '@kbn/core-pricing-server-internal';
1313

1414
const createSetupContractMock = () => {
1515
const setupContract: jest.Mocked<PricingServiceSetup> = {
16+
isFeatureAvailable: jest.fn(),
1617
registerProductFeatures: jest.fn(),
1718
};
1819
return setupContract;

src/core/packages/pricing/server/src/contracts.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ import type { IPricingTiersClient, PricingProductFeature } from '@kbn/core-prici
1818
* @public
1919
*/
2020
export interface PricingServiceSetup {
21+
/**
22+
* Check if a specific feature is available in the current pricing tier configuration.
23+
* Resolves asynchronously after the pricing service has been set up and all the plugins have registered their features.
24+
*
25+
* @example
26+
* ```ts
27+
* // my-plugin/server/plugin.ts
28+
* public setup(core: CoreSetup) {
29+
* const isPremiumFeatureAvailable = core.pricing.isFeatureAvailable('my_premium_feature');
30+
* }
31+
* ```
32+
*/
33+
isFeatureAvailable(featureId: string): Promise<boolean>;
2134
/**
2235
* Register product features that are available in specific pricing tiers.
2336
*

src/core/packages/root/server-internal/src/server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,13 @@ export class Server {
395395

396396
const pluginsSetup = await this.plugins.setup(coreSetup);
397397
this.#pluginsInitialized = pluginsSetup.initialized;
398+
/**
399+
* This is a necessary step to ensure that the pricing service is ready to be used.
400+
* It must be called after all plugins have been setup.
401+
* This guarantee that all plugins checking for a feature availability with isFeatureAvailable
402+
* in the server setup contract get the right access to the feature availability.
403+
*/
404+
pricingSetup.evaluateProductFeatures();
398405

399406
this.registerCoreContext(coreSetup);
400407
await this.coreApp.setup(coreSetup, uiPlugins);

0 commit comments

Comments
 (0)