Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
OpenFeature,
StringFlagKey,
} from '@openfeature/web-sdk';
import { setAngularFrameworkMetadata } from './framework-metadata';

/**
* Represents the template context provided by feature flag structural directives
Expand Down Expand Up @@ -132,7 +133,7 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
if (this._client) {
this.disposeClient(this._client);
}
this._client = OpenFeature.getClient(this._featureFlagDomain);
this._client = setAngularFrameworkMetadata(OpenFeature.getClient(this._featureFlagDomain));

const baseHandler = () => {
const result = this.getFlagDetails(this._featureFlagKey, this._featureFlagDefault);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AsyncPipe } from '@angular/common';
import { TestingProvider } from '../test/test.utils';
import { OpenFeatureModule } from './open-feature.module';
import { toSignal } from '@angular/core/rxjs-interop';
import { vi } from 'vitest';

const FLAG_KEY = 'thumbs';

Expand Down Expand Up @@ -114,6 +115,7 @@ describe('FeatureFlagService', () => {

afterEach(async () => {
await OpenFeature.close();
OpenFeature.clearHooks();
await OpenFeature.setContext({});
currentTestComponentFixture?.destroy();
currentContextChangeDisabledComponentFixture?.destroy();
Expand All @@ -129,6 +131,27 @@ describe('FeatureFlagService', () => {
expect(observableValue?.textContent).toBe('👍');
});

it('should surface angular metadata in hook contexts', async () => {
const hook = { before: vi.fn() };

OpenFeature.addHooks(hook);
await createTestingModule();
service = TestBed.inject(FeatureFlagService);

await firstValueFrom(service.getBooleanDetails(FLAG_KEY, false));

expect(hook.before).toHaveBeenCalledWith(
expect.objectContaining({
clientMetadata: expect.objectContaining({
sdk: 'js-web',
paradigm: 'client',
framework: 'angular',
}),
}),
undefined,
);
});

it('should render updated value after delay', async () => {
const delay = 50;
await createTestingModule({ providerInitDelay: delay });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ProviderStatus,
StringFlagKey,
} from '@openfeature/web-sdk';
import { setAngularFrameworkMetadata } from './framework-metadata';
import { isEqual } from './internal/is-equal';

export type AngularFlagEvaluationOptions = {
Expand Down Expand Up @@ -217,7 +218,7 @@ export class FeatureFlagService {
domain: string | undefined,
options?: AngularFlagEvaluationOptions,
): Observable<EvaluationDetails<T>> {
const client = domain ? OpenFeature.getClient(domain) : OpenFeature.getClient();
const client = setAngularFrameworkMetadata(OpenFeature.getClient(domain || undefined));

return new Observable<EvaluationDetails<T>>((subscriber) => {
let currentResult: EvaluationDetails<T> | undefined = undefined;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Client } from '@openfeature/web-sdk';

type FrameworkMetadataClient = Client & {
setFrameworkMetadata?: (framework: 'angular') => Client;
};

/**
* Marks an SDK-owned web client as Angular-backed while preserving instance identity.
* @param {Client} client client instance to update
* @returns {Client} the same client instance
*/
export function setAngularFrameworkMetadata(client: Client): Client {
(client as FrameworkMetadataClient).setFrameworkMetadata?.('angular');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I'm particularly a fan of relying on type-casts to enable this

return client;
}
15 changes: 15 additions & 0 deletions packages/nest/src/framework-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Client } from '@openfeature/server-sdk';

type FrameworkMetadataClient = Client & {
setFrameworkMetadata?: (framework: 'nest') => Client;
};

/**
* Marks an SDK-owned server client as Nest-backed while preserving instance identity.
* @param {Client} client client instance to update
* @returns {Client} the same client instance
*/
export function setNestFrameworkMetadata(client: Client): Client {
(client as FrameworkMetadataClient).setFrameworkMetadata?.('nest');
return client;
}
5 changes: 3 additions & 2 deletions packages/nest/src/open-feature.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { ContextFactory } from './context-factory';
import { ContextFactoryToken } from './context-factory';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { EvaluationContextInterceptor } from './evaluation-context-interceptor';
import { setNestFrameworkMetadata } from './framework-metadata';
import { ShutdownService } from './shutdown.service';

/**
Expand All @@ -45,7 +46,7 @@ export class OpenFeatureModule {
const clientValueProviders: NestFactoryProvider<Client>[] = [
{
provide: getOpenFeatureClientToken(),
useFactory: () => OpenFeature.getClient(),
useFactory: () => setNestFrameworkMetadata(OpenFeature.getClient()),
},
];

Expand All @@ -58,7 +59,7 @@ export class OpenFeatureModule {
OpenFeature.setProvider(domain, provider);
clientValueProviders.push({
provide: getOpenFeatureClientToken(domain),
useFactory: () => OpenFeature.getClient(domain),
useFactory: () => setNestFrameworkMetadata(OpenFeature.getClient(domain)),
});
});
}
Expand Down
3 changes: 2 additions & 1 deletion packages/nest/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Client, EvaluationContext } from '@openfeature/server-sdk';
import { OpenFeature } from '@openfeature/server-sdk';
import { setNestFrameworkMetadata } from './framework-metadata';

/**
* Returns a domain scoped or the default OpenFeature client with the given context.
Expand All @@ -8,5 +9,5 @@ import { OpenFeature } from '@openfeature/server-sdk';
* @returns {Client} The OpenFeature client.
*/
export function getClientForEvaluation(domain?: string, context?: EvaluationContext) {
return domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context);
return setNestFrameworkMetadata(domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context));
}
44 changes: 43 additions & 1 deletion packages/nest/test/open-feature.module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Test } from '@nestjs/testing';
import { getOpenFeatureClientToken, OpenFeatureModule, ServerProviderEvents } from '../src';
import type { Client } from '@openfeature/server-sdk';
import { OpenFeature } from '@openfeature/server-sdk';
import { getOpenFeatureDefaultTestModule } from './fixtures';
import { defaultProvider, getOpenFeatureDefaultTestModule } from './fixtures';

describe('OpenFeatureModule', () => {
let moduleRef: TestingModule;
Expand Down Expand Up @@ -49,6 +49,48 @@ describe('OpenFeatureModule', () => {
expect(client).toBeDefined();
expect(await client.getStringValue('testStringFlag', '')).toEqual('expected-string-value-scoped');
});

it('should expose nest framework metadata on injected clients', () => {
const defaultClient = moduleRef.get<Client>(getOpenFeatureClientToken());
const scopedClient = moduleRef.get<Client>(getOpenFeatureClientToken('domainScopedClient'));

expect(defaultClient.metadata).toMatchObject({
sdk: 'js-server',
paradigm: 'server',
framework: 'nest',
});
expect(scopedClient.metadata).toMatchObject({
sdk: 'js-server',
paradigm: 'server',
framework: 'nest',
});
});

it('should surface nest metadata in hook contexts', async () => {
const hook = { before: jest.fn() };
const hookModuleRef = await Test.createTestingModule({
imports: [OpenFeatureModule.forRoot({ defaultProvider, hooks: [hook] })],
}).compile();

try {
const client = hookModuleRef.get<Client>(getOpenFeatureClientToken());
await client.getBooleanValue('testBooleanFlag', false);

expect(hook.before).toHaveBeenCalledWith(
expect.objectContaining({
clientMetadata: expect.objectContaining({
sdk: 'js-server',
paradigm: 'server',
framework: 'nest',
}),
}),
undefined,
);
} finally {
await hookModuleRef.close();
OpenFeature.clearHooks();
}
});
});

describe('handlers', () => {
Expand Down
21 changes: 20 additions & 1 deletion packages/react/src/provider/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import * as React from 'react';
import type { ReactFlagEvaluationOptions } from '../options';
import { Context } from '../internal';

type FrameworkMetadataClient = Client & {
setFrameworkMetadata?: (framework: 'react') => Client;
};

type ClientOrDomain =
| {
/**
Expand Down Expand Up @@ -32,7 +36,22 @@ type ProviderProps = {
* @returns {OpenFeatureProvider} context provider
*/
export function OpenFeatureProvider({ client, domain, children, ...options }: ProviderProps) {
const stableClient = React.useMemo(() => client || OpenFeature.getClient(domain), [client, domain]);
const stableClient = React.useMemo(() => {
if (client) {
return setReactFrameworkMetadata(client);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mutates a user-provided client that might also be used elsewhere, this seems problematic?

}

return setReactFrameworkMetadata(OpenFeature.getClient(domain));
}, [client, domain]);

return <Context.Provider value={{ client: stableClient, options }}>{children}</Context.Provider>;
}

function setReactFrameworkMetadata(client: Client): Client {
// When a caller provides an existing client, preserve that instance but mark it
// as React-backed so metadata stays aligned with provider-created clients.
// The cast is needed because `setFrameworkMetadata` is an internal method on the
// SDK-owned client implementation, not part of the public `Client` interface.
(client as FrameworkMetadataClient).setFrameworkMetadata?.('react');
return client;
}
52 changes: 51 additions & 1 deletion packages/react/test/provider.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,14 @@ describe('OpenFeatureProvider', () => {

beforeEach(async () => {
await OpenFeature.clearContexts();
OpenFeature.clearHooks();
});

describe('useOpenFeatureClient', () => {
const DOMAIN = 'useOpenFeatureClient';

describe('client specified', () => {
it('should return client from provider', () => {
it('should return client from provider with react metadata', () => {
const client = OpenFeature.getClient(DOMAIN);

const wrapper = ({ children }: Parameters<typeof OpenFeatureProvider>[0]) => (
Expand All @@ -69,6 +70,12 @@ describe('OpenFeatureProvider', () => {
const { result } = renderHook(() => useOpenFeatureClient(), { wrapper });

expect(result.current).toEqual(client);
expect(result.current.metadata).toMatchObject({
domain: DOMAIN,
sdk: 'js-web',
paradigm: 'client',
framework: 'react',
});
});
});

Expand All @@ -81,6 +88,9 @@ describe('OpenFeatureProvider', () => {
const { result } = renderHook(() => useOpenFeatureClient(), { wrapper });

expect(result.current.metadata.domain).toEqual(DOMAIN);
expect(result.current.metadata.sdk).toEqual('js-web');
expect(result.current.metadata.paradigm).toEqual('client');
expect(result.current.metadata.framework).toEqual('react');
});

it('should return a stable client across renders', () => {
Expand All @@ -96,6 +106,46 @@ describe('OpenFeatureProvider', () => {

expect(firstClient).toBe(secondClient);
});

it('should surface react metadata in hook contexts', async () => {
const hook = { before: jest.fn() };

OpenFeature.setProvider(
DOMAIN,
new InMemoryProvider({
greeting: {
disabled: false,
variants: { default: 'hello' },
defaultVariant: 'default',
},
}),
);

const wrapper = ({ children }: Parameters<typeof OpenFeatureProvider>[0]) => (
<OpenFeatureProvider domain={DOMAIN}>{children}</OpenFeatureProvider>
);

const { result } = renderHook(
() =>
useStringFlagValue('greeting', 'fallback', {
hooks: [hook],
}),
{ wrapper },
);

await waitFor(() => expect(result.current).toEqual('hello'));
expect(hook.before).toHaveBeenCalledWith(
expect.objectContaining({
clientMetadata: expect.objectContaining({
domain: DOMAIN,
sdk: 'js-web',
paradigm: 'client',
framework: 'react',
}),
}),
undefined,
);
});
});
});

Expand Down
6 changes: 3 additions & 3 deletions packages/server/src/client/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ClientMetadata, EvaluationLifeCycle, Eventing, ManageContext, ManageLogger } from '@openfeature/core';
import type { EvaluationLifeCycle, Eventing, ManageContext, ManageLogger, MetadataClient } from '@openfeature/core';
import type { Features } from '../evaluation';
import type { ProviderStatus } from '../provider';
import type { ProviderEvents } from '../events';
Expand All @@ -11,8 +11,8 @@ export interface Client
ManageContext<Client>,
ManageLogger<Client>,
Tracking,
Eventing<ProviderEvents> {
readonly metadata: ClientMetadata;
Eventing<ProviderEvents>,
MetadataClient {
/**
* Returns the status of the associated provider.
*/
Expand Down
21 changes: 21 additions & 0 deletions packages/server/src/client/internal/open-feature-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
ClientFramework,
ClientMetadata,
EvaluationContext,
EvaluationDetails,
Expand Down Expand Up @@ -50,6 +51,7 @@ export class OpenFeatureClient implements Client {
private _context: EvaluationContext;
private _hooks: Hook[] = [];
private _clientLogger?: Logger;
private _framework?: ClientFramework;

constructor(
// we always want the client to use the current provider,
Expand All @@ -73,10 +75,29 @@ export class OpenFeatureClient implements Client {
name: this.options.domain ?? this.options.name,
domain: this.options.domain ?? this.options.name,
version: this.options.version,
sdk: 'js-server',
paradigm: 'server',
framework: this._framework,
providerMetadata: this.providerAccessor().metadata,
Comment thread
jonathannorris marked this conversation as resolved.
};
}

/**
* Sets framework metadata on an existing SDK-owned client instance.
*
* This is used by framework wrappers that must preserve a pre-created client
* instance instead of constructing a new framework-aware client. Framework
* packages cast to this internal method because it is intentionally not part
* of the public `Client` interface.
* @param {ClientFramework} framework framework metadata to expose
* @returns {this} the updated client
* @internal
*/
setFrameworkMetadata(framework: ClientFramework): this {
this._framework = framework;
return this;
}

get providerStatus(): ProviderStatus {
return this.providerStatusAccessor();
}
Expand Down
Loading
Loading