Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions libs/providers/flagd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,25 @@ To enable this mode, you should provide a valid flag configuration file with the
Offline mode uses `fs.watchFile` and polls every 5 seconds for changes to the file.
This mode is useful for local development, test cases, and for offline applications.

### Selector (in-process mode only)

The `selector` option allows filtering flag configurations from the sync service in in-process mode. This is useful when multiple flag configurations are available and you want to target a specific subset.

```ts
OpenFeature.setProvider(new FlagdProvider({
resolverType: 'in-process',
selector: 'app=weather',
}))
```

> [!NOTE]
> **Selector Implementation Details**: As of this release, the selector is sent to flagd via both:
> 1. The `flagd-selector` gRPC metadata header (new standard, see [flagd#1814](https://github.com/open-feature/flagd/issues/1814))
> 2. The `selector` field in the `SyncFlagsRequest` message (deprecated, for backward compatibility)
>
> The request field will be removed in a future major version once the deprecation period ends.
> This dual approach ensures compatibility with both newer and older flagd versions during the transition period.

### Default Authority usage (optional)

This is useful for complex routing or service-discovery use cases that involve a proxy (e.g., Envoy).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { GrpcFetch } from './grpc-fetch';
import type { Config } from '../../../configuration';
import type { FlagSyncServiceClient, SyncFlagsResponse } from '../../../../proto/ts/flagd/sync/v1/sync';
import { ConnectivityState } from '@grpc/grpc-js/build/src/connectivity-state';
import type { Metadata } from '@grpc/grpc-js';

let watchStateCallback: () => void = () => ({});
const mockChannel = {
Expand Down Expand Up @@ -143,4 +144,55 @@ describe('grpc fetch', () => {

onErrorCallback(new Error('Some connection error'));
});

it('should send selector via flagd-selector metadata header', async () => {
const selector = 'app=weather';
const cfgWithSelector: Config = { ...cfg, selector };
const flagConfiguration = '{"flags":{}}';

const fetch = new GrpcFetch(cfgWithSelector, serviceMock);
const connectPromise = fetch.connect(dataCallback, reconnectCallback, jest.fn(), disconnectCallback);
onDataCallback({ flagConfiguration });
await connectPromise;

// Verify syncFlags was called
expect(serviceMock.syncFlags).toHaveBeenCalled();

// Check that both request and metadata were passed
const callArgs = (serviceMock.syncFlags as jest.Mock).mock.calls[0];
expect(callArgs).toHaveLength(2);

// Verify the request contains selector (for backward compatibility)
expect(callArgs[0].selector).toBe(selector);

// Verify the metadata contains flagd-selector header
const metadata = callArgs[1] as Metadata;
expect(metadata).toBeDefined();
expect(metadata.get('flagd-selector')).toEqual([selector]);
});

it('should handle empty selector gracefully', async () => {
const cfgWithoutSelector: Config = { ...cfg, selector: '' };
const flagConfiguration = '{"flags":{}}';

const fetch = new GrpcFetch(cfgWithoutSelector, serviceMock);
const connectPromise = fetch.connect(dataCallback, reconnectCallback, jest.fn(), disconnectCallback);
onDataCallback({ flagConfiguration });
await connectPromise;

// Verify syncFlags was called
expect(serviceMock.syncFlags).toHaveBeenCalled();

// Check that both request and metadata were passed
const callArgs = (serviceMock.syncFlags as jest.Mock).mock.calls[0];
expect(callArgs).toHaveLength(2);

// Verify the request contains empty selector
expect(callArgs[0].selector).toBe('');

// Verify the metadata does not contain flagd-selector header
const metadata = callArgs[1] as Metadata;
expect(metadata).toBeDefined();
expect(metadata.get('flagd-selector')).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ClientReadableStream, ServiceError, ClientOptions } from '@grpc/grpc-js';
import { credentials } from '@grpc/grpc-js';
import { credentials, Metadata } from '@grpc/grpc-js';
import type { Logger } from '@openfeature/server-sdk';
import { GeneralError } from '@openfeature/server-sdk';
import type { SyncFlagsRequest, SyncFlagsResponse } from '../../../../proto/ts/flagd/sync/v1/sync';
Expand All @@ -14,6 +14,7 @@ import type { DataFetch } from '../data-fetch';
export class GrpcFetch implements DataFetch {
private readonly _syncClient: FlagSyncServiceClient;
private readonly _request: SyncFlagsRequest;
private readonly _metadata: Metadata;
private _syncStream: ClientReadableStream<SyncFlagsResponse> | undefined;
private _logger: Logger | undefined;
/**
Expand Down Expand Up @@ -47,7 +48,16 @@ export class GrpcFetch implements DataFetch {
);

this._logger = logger;
// For backward compatibility during the deprecation period, we send the selector in both:
// 1. The request field (deprecated, for older flagd versions)
// 2. The gRPC metadata header 'flagd-selector' (new standard)
this._request = { providerId: '', selector: selector ? selector : '' };

// Create metadata with the flagd-selector header
this._metadata = new Metadata();
if (selector) {
this._metadata.set('flagd-selector', selector);
}
}

async connect(
Expand Down Expand Up @@ -79,7 +89,7 @@ export class GrpcFetch implements DataFetch {
this._logger?.debug('Starting gRPC sync connection');
closeStreamIfDefined(this._syncStream);
try {
this._syncStream = this._syncClient.syncFlags(this._request);
this._syncStream = this._syncClient.syncFlags(this._request, this._metadata);
this._syncStream.on('data', (data: SyncFlagsResponse) => {
this._logger?.debug(`Received sync payload`);

Expand Down
Loading