diff --git a/api/sdk.api.md b/api/sdk.api.md index 8e8c5025..c2065571 100644 --- a/api/sdk.api.md +++ b/api/sdk.api.md @@ -13,6 +13,8 @@ export interface CacheOptions { // // @internal (undocumented) entries?: AsyncIterable; + // @internal + loggedFlags?: Set; scope?: CacheScope; } diff --git a/packages/sdk/src/Confidence.test.ts b/packages/sdk/src/Confidence.test.ts index 425d2d2a..238325ee 100644 --- a/packages/sdk/src/Confidence.test.ts +++ b/packages/sdk/src/Confidence.test.ts @@ -21,17 +21,28 @@ describe('Confidence', () => { variant: 'mockVariant', }; + const loggerSpy = { + infoLogs: [] as string[], + info: (input: string) => { + loggerSpy.infoLogs.push(input); + }, + }; + beforeEach(() => { + loggerSpy.infoLogs = []; confidence = new Confidence({ clientSecret: 'secret', timeout: 10, environment: 'client', - logger: {}, + logger: loggerSpy, eventSenderEngine: eventSenderEngineMock, flagResolverClient: flagResolverClientMock, cacheProvider: () => { throw new Error('Not implemented'); }, + cache: { + loggedFlags: new Set(), + }, }); flagResolverClientMock.resolve.mockImplementation((context, _flags) => { const flagResolution = new Promise(resolve => { @@ -377,5 +388,32 @@ describe('Confidence', () => { variant: 'mockVariant', }); }); + + it('should log the flag resolve hint once per and context and flag', async () => { + const ctx = { targeting_key: 'default', pantsOn: true, pantsColor: 'blue' }; + const c = confidence.withContext(ctx); + await c.evaluateFlag('flag1', 'default'); + c.getFlag('flag1', 'default'); + + expect(loggerSpy.infoLogs.length).toEqual(1); + expect(loggerSpy.infoLogs[0]).toEqual( + "See resolves for 'flag1' in Confidence: https://app.confidence.spotify.com/flags/resolver-test?client-key=secret&flag=flags/flag1&context=%7B%22targeting_key%22%3A%22default%22%2C%22pantsOn%22%3Atrue%2C%22pantsColor%22%3A%22blue%22%7D", + ); + c.getFlag('flag1', 'default'); + expect(loggerSpy.infoLogs.length).toEqual(1); + + await c.evaluateFlag('flag2', 'default'); + expect(loggerSpy.infoLogs.length).toEqual(2); + expect(loggerSpy.infoLogs[1]).toEqual( + "See resolves for 'flag2' in Confidence: https://app.confidence.spotify.com/flags/resolver-test?client-key=secret&flag=flags/flag2&context=%7B%22targeting_key%22%3A%22default%22%2C%22pantsOn%22%3Atrue%2C%22pantsColor%22%3A%22blue%22%7D", + ); + const c2 = c.withContext({ pantsOn: false }); + await c2.evaluateFlag('flag2', 'default'); + c2.getFlag('flag2', 'default'); + expect(loggerSpy.infoLogs.length).toEqual(3); + expect(loggerSpy.infoLogs[2]).toEqual( + "See resolves for 'flag2' in Confidence: https://app.confidence.spotify.com/flags/resolver-test?client-key=secret&flag=flags/flag2&context=%7B%22targeting_key%22%3A%22default%22%2C%22pantsColor%22%3A%22blue%22%2C%22pantsOn%22%3Afalse%7D", + ); + }); }); }); diff --git a/packages/sdk/src/Confidence.ts b/packages/sdk/src/Confidence.ts index a09362d8..f5e34b13 100644 --- a/packages/sdk/src/Confidence.ts +++ b/packages/sdk/src/Confidence.ts @@ -329,11 +329,15 @@ export class Confidence implements EventSender, Trackable, FlagResolver { } private showLoggerLink(flag: string, context: Context) { - this.config.logger.info?.( - `See resolves for '${flag}' in Confidence: https://app.confidence.spotify.com/flags/resolver-test?client-key=${ - this.config.clientSecret - }&flag=flags/${flag}&context=${encodeURIComponent(JSON.stringify(context))}`, - ); + const logKey = `${flag}:${Value.serialize(context)}`; + if (!this.config.cache?.loggedFlags?.has(logKey)) { + this.config.cache?.loggedFlags?.add(logKey); + this.config.logger.info?.( + `See resolves for '${flag}' in Confidence: https://app.confidence.spotify.com/flags/resolver-test?client-key=${ + this.config.clientSecret + }&flag=flags/${flag}&context=${encodeURIComponent(JSON.stringify(context))}`, + ); + } } toOptions(): ConfidenceOptions { @@ -371,7 +375,9 @@ export class Confidence implements EventSender, Trackable, FlagResolver { disableTelemetry = false, applyDebounce = 10, waitUntil, - cache = {}, + cache = { + loggedFlags: new Set(), + }, } = options; if (environment !== 'client' && environment !== 'backend') { throw new Error(`Invalid environment: ${environment}. Must be 'client' or 'backend'.`); diff --git a/packages/sdk/src/flag-cache.ts b/packages/sdk/src/flag-cache.ts index 69bde8fc..a20dce4f 100644 --- a/packages/sdk/src/flag-cache.ts +++ b/packages/sdk/src/flag-cache.ts @@ -36,8 +36,26 @@ export interface CacheOptions { scope?: CacheScope; /** @internal */ entries?: AsyncIterable; + /** + * Flags that have been logged using the showLoggerLink method. + * @internal + */ + loggedFlags?: Set; } + +type FlagCacheOptions = { + loggedFlags?: Set; +}; export class FlagCache extends AbstractCache { + /** + * Flags that have been logged using the showLoggerLink method. + * @internal + */ + private readonly logs: Set; + constructor(options: FlagCacheOptions) { + super(); + this.logs = options.loggedFlags ?? new Set(); + } protected serialize(value: ResolveFlagsResponse): Uint8Array { return ResolveFlagsResponse.encode(value).finish(); } @@ -62,6 +80,7 @@ export class FlagCache extends AbstractCache new FlagCache()); + export function provider( + clientKey: string, + { scope = defaultScope(), entries, loggedFlags }: CacheOptions, + ): CacheProvider { + const provider = scope(() => new FlagCache({ loggedFlags })); if (entries) { provider(clientKey).load(entries); }