Skip to content

Commit 8e82dac

Browse files
authored
[sc-170667] Add basic logging support. (#4)
1 parent 656d6e8 commit 8e82dac

File tree

8 files changed

+275
-34
lines changed

8 files changed

+275
-34
lines changed

__tests__/LaunchDarklyProvider.test.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,23 @@ import { OpenFeature, Client } from '@openfeature/js-sdk';
22
import { LDClient } from 'launchdarkly-node-server-sdk';
33
import { LaunchDarklyProvider } from '../src';
44
import translateContext from '../src/translateContext';
5+
import TestLogger from './TestLogger';
56

67
const basicContext = { targetingKey: 'the-key' };
78
const testFlagKey = 'a-key';
89

910
describe('given a mock LaunchDarkly client', () => {
1011
let ldClient: LDClient;
1112
let ofClient: Client;
13+
const logger: TestLogger = new TestLogger();
1214

1315
beforeEach(() => {
1416
ldClient = {
1517
variationDetail: jest.fn(),
1618
} as any;
17-
OpenFeature.setProvider(new LaunchDarklyProvider(ldClient));
19+
OpenFeature.setProvider(new LaunchDarklyProvider(ldClient, { logger }));
1820
ofClient = OpenFeature.getClient();
21+
logger.reset();
1922
});
2023

2124
it('calls the client correctly for boolean variations', async () => {
@@ -27,11 +30,11 @@ describe('given a mock LaunchDarkly client', () => {
2730
}));
2831
await ofClient.getBooleanDetails(testFlagKey, false, basicContext);
2932
expect(ldClient.variationDetail)
30-
.toHaveBeenCalledWith(testFlagKey, translateContext(basicContext), false);
33+
.toHaveBeenCalledWith(testFlagKey, translateContext(logger, basicContext), false);
3134
jest.clearAllMocks();
3235
await ofClient.getBooleanValue(testFlagKey, false, basicContext);
3336
expect(ldClient.variationDetail)
34-
.toHaveBeenCalledWith(testFlagKey, translateContext(basicContext), false);
37+
.toHaveBeenCalledWith(testFlagKey, translateContext(logger, basicContext), false);
3538
});
3639

3740
it('handles correct return types for boolean variations', async () => {
@@ -74,11 +77,11 @@ describe('given a mock LaunchDarkly client', () => {
7477
}));
7578
await ofClient.getStringDetails(testFlagKey, 'default', basicContext);
7679
expect(ldClient.variationDetail)
77-
.toHaveBeenCalledWith(testFlagKey, translateContext(basicContext), 'default');
80+
.toHaveBeenCalledWith(testFlagKey, translateContext(logger, basicContext), 'default');
7881
jest.clearAllMocks();
7982
await ofClient.getStringValue(testFlagKey, 'default', basicContext);
8083
expect(ldClient.variationDetail)
81-
.toHaveBeenCalledWith(testFlagKey, translateContext(basicContext), 'default');
84+
.toHaveBeenCalledWith(testFlagKey, translateContext(logger, basicContext), 'default');
8285
});
8386

8487
it('handles correct return types for string variations', async () => {
@@ -121,11 +124,11 @@ describe('given a mock LaunchDarkly client', () => {
121124
}));
122125
await ofClient.getNumberDetails(testFlagKey, 0, basicContext);
123126
expect(ldClient.variationDetail)
124-
.toHaveBeenCalledWith(testFlagKey, translateContext(basicContext), 0);
127+
.toHaveBeenCalledWith(testFlagKey, translateContext(logger, basicContext), 0);
125128
jest.clearAllMocks();
126129
await ofClient.getNumberValue(testFlagKey, 0, basicContext);
127130
expect(ldClient.variationDetail)
128-
.toHaveBeenCalledWith(testFlagKey, translateContext(basicContext), 0);
131+
.toHaveBeenCalledWith(testFlagKey, translateContext(logger, basicContext), 0);
129132
});
130133

131134
it('handles correct return types for numeric variations', async () => {
@@ -168,11 +171,11 @@ describe('given a mock LaunchDarkly client', () => {
168171
}));
169172
await ofClient.getObjectDetails(testFlagKey, {}, basicContext);
170173
expect(ldClient.variationDetail)
171-
.toHaveBeenCalledWith(testFlagKey, translateContext(basicContext), {});
174+
.toHaveBeenCalledWith(testFlagKey, translateContext(logger, basicContext), {});
172175
jest.clearAllMocks();
173176
await ofClient.getObjectValue(testFlagKey, {}, basicContext);
174177
expect(ldClient.variationDetail)
175-
.toHaveBeenCalledWith(testFlagKey, translateContext(basicContext), {});
178+
.toHaveBeenCalledWith(testFlagKey, translateContext(logger, basicContext), {});
176179
});
177180

178181
it('handles correct return types for object variations', async () => {
@@ -246,4 +249,16 @@ describe('given a mock LaunchDarkly client', () => {
246249
reason: 'OFF',
247250
});
248251
});
252+
253+
it('logs information about missing keys', async () => {
254+
await ofClient.getObjectDetails(testFlagKey, {}, {});
255+
expect(logger.logs[0]).toEqual("The EvaluationContext must contain either a 'targetingKey' "
256+
+ "or a 'key' and the type must be a string.");
257+
});
258+
259+
it('logs information about double keys', async () => {
260+
await ofClient.getObjectDetails(testFlagKey, {}, { targetingKey: '1', key: '2' });
261+
expect(logger.logs[0]).toEqual("The EvaluationContext contained both a 'targetingKey' and a"
262+
+ " 'key' attribute. The 'key' attribute will be discarded.");
263+
});
249264
});

__tests__/SafeLogger.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { basicLogger, LDLogger } from 'launchdarkly-node-server-sdk';
2+
import SafeLogger from '../src/SafeLogger';
3+
4+
it('throws when constructed with an invalid logger', () => {
5+
expect(
6+
() => new SafeLogger({} as LDLogger, basicLogger({})),
7+
).toThrow();
8+
});
9+
10+
describe('given a logger that throws in logs', () => {
11+
const strings: string[] = [];
12+
const logger = new SafeLogger({
13+
info: () => { throw new Error('info'); },
14+
debug: () => { throw new Error('info'); },
15+
warn: () => { throw new Error('info'); },
16+
error: () => { throw new Error('info'); },
17+
}, basicLogger({
18+
level: 'debug',
19+
destination: (...args: any) => {
20+
strings.push(args.join(' '));
21+
},
22+
}));
23+
24+
it('uses the fallback logger', () => {
25+
logger.debug('a');
26+
logger.info('b');
27+
logger.warn('c');
28+
logger.error('d');
29+
expect(strings).toEqual([
30+
'debug: [LaunchDarkly] a',
31+
'info: [LaunchDarkly] b',
32+
'warn: [LaunchDarkly] c',
33+
'error: [LaunchDarkly] d',
34+
]);
35+
});
36+
});

__tests__/TestLogger.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { LDLogger } from 'launchdarkly-node-server-sdk';
2+
3+
export default class TestLogger implements LDLogger {
4+
public logs: string[] = [];
5+
6+
error(...args: any[]): void {
7+
this.logs.push(args.join(' '));
8+
}
9+
10+
warn(...args: any[]): void {
11+
this.logs.push(args.join(' '));
12+
}
13+
14+
info(...args: any[]): void {
15+
this.logs.push(args.join(' '));
16+
}
17+
18+
debug(...args: any[]): void {
19+
this.logs.push(args.join(' '));
20+
}
21+
22+
reset() {
23+
this.logs = [];
24+
}
25+
}

__tests__/translateContext.test.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import translateContext from '../src/translateContext';
2+
import TestLogger from './TestLogger';
23

34
it('Uses the targetingKey as the user key', () => {
4-
expect(translateContext({ targetingKey: 'the-key' })).toEqual({ key: 'the-key' });
5+
const logger = new TestLogger();
6+
expect(translateContext(logger, { targetingKey: 'the-key' })).toEqual({ key: 'the-key' });
7+
expect(logger.logs.length).toEqual(0);
58
});
69

710
describe.each([
@@ -15,13 +18,16 @@ describe.each([
1518
['country', 'value8'],
1619
['anonymous', true],
1720
])('given correct built-in attributes', (key, value) => {
21+
const logger = new TestLogger();
1822
it('translates the key correctly', () => {
1923
expect(translateContext(
24+
logger,
2025
{ targetingKey: 'the-key', [key]: value },
2126
)).toEqual({
2227
key: 'the-key',
2328
[key]: value,
2429
});
30+
expect(logger.logs.length).toEqual(0);
2531
});
2632
});
2733

@@ -37,31 +43,39 @@ describe.each([
3743
['anonymous', 'value'],
3844
])('given incorrect built-in attributes', (key, value) => {
3945
it('the bad key is omitted', () => {
46+
const logger = new TestLogger();
4047
expect(translateContext(
48+
logger,
4149
{ targetingKey: 'the-key', [key]: value },
4250
)).toEqual({
4351
key: 'the-key',
4452
});
53+
expect(logger.logs[0]).toMatch(new RegExp(`The attribute '${key}' must be of type.*`));
4554
});
4655
});
4756

4857
it('accepts custom attributes', () => {
49-
expect(translateContext({ targetingKey: 'the-key', someAttr: 'someValue' })).toEqual({
58+
const logger = new TestLogger();
59+
expect(translateContext(logger, { targetingKey: 'the-key', someAttr: 'someValue' })).toEqual({
5060
key: 'the-key',
5161
custom: {
5262
someAttr: 'someValue',
5363
},
5464
});
65+
expect(logger.logs.length).toEqual(0);
5566
});
5667

5768
it('ignores custom attributes that are objects', () => {
58-
expect(translateContext({ targetingKey: 'the-key', someAttr: {} })).toEqual({
69+
const logger = new TestLogger();
70+
expect(translateContext(logger, { targetingKey: 'the-key', someAttr: {} })).toEqual({
5971
key: 'the-key',
6072
});
73+
expect(logger.logs[0]).toEqual("The attribute 'someAttr' is of an unsupported type 'object'");
6174
});
6275

6376
it('accepts string/boolean/number arrays', () => {
64-
expect(translateContext({
77+
const logger = new TestLogger();
78+
expect(translateContext(logger, {
6579
targetingKey: 'the-key',
6680
strings: ['a', 'b', 'c'],
6781
numbers: [1, 2, 3],
@@ -74,24 +88,34 @@ it('accepts string/boolean/number arrays', () => {
7488
booleans: [true, false],
7589
},
7690
});
91+
expect(logger.logs.length).toEqual(0);
7792
});
7893

7994
it('discards invalid array types', () => {
80-
expect(translateContext({
81-
targetingKey: 'the-key',
82-
mixedTypes: [true, 'b', 1],
83-
dates: [new Date()],
84-
})).toEqual({
95+
const logger = new TestLogger();
96+
expect(translateContext(
97+
logger,
98+
{
99+
targetingKey: 'the-key',
100+
dates: [new Date()],
101+
},
102+
)).toEqual({
85103
key: 'the-key',
86104
});
105+
expect(logger.logs[0]).toEqual("The attribute 'dates' is an unsupported array type.");
87106
});
88107

89108
it('converts date to ISO strings', () => {
90109
const date = new Date();
91-
expect(translateContext({ targetingKey: 'the-key', date })).toEqual({
110+
const logger = new TestLogger();
111+
expect(translateContext(
112+
logger,
113+
{ targetingKey: 'the-key', date },
114+
)).toEqual({
92115
key: 'the-key',
93116
custom: {
94117
date: date.toISOString(),
95118
},
96119
});
120+
expect(logger.logs.length).toEqual(0);
97121
});

src/LaunchDarklyProvider.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import {
33
JsonValue,
44
Provider, ProviderMetadata, ResolutionDetails,
55
} from '@openfeature/js-sdk';
6-
import { LDClient } from 'launchdarkly-node-server-sdk';
6+
import {
7+
basicLogger, LDClient, LDLogger,
8+
} from 'launchdarkly-node-server-sdk';
9+
import { LaunchDarklyProviderOptions } from './LaunchDarklyProviderOptions';
710
import translateContext from './translateContext';
811
import translateResult from './translateResult';
912

@@ -25,6 +28,8 @@ function wrongTypeResult<T>(value: T): ResolutionDetails<T> {
2528
* An OpenFeature provider for the LaunchDarkly SDK for node.
2629
*/
2730
export default class LaunchDarklyProvider implements Provider {
31+
private readonly logger: LDLogger;
32+
2833
readonly metadata: ProviderMetadata = {
2934
name: 'launchdarkly-node-provider',
3035
};
@@ -33,7 +38,12 @@ export default class LaunchDarklyProvider implements Provider {
3338
* Construct a {@link LaunchDarklyProvider}.
3439
* @param client The LaunchDarkly client instance to use.
3540
*/
36-
constructor(private readonly client: LDClient) {
41+
constructor(private readonly client: LDClient, options: LaunchDarklyProviderOptions = {}) {
42+
if (options.logger) {
43+
this.logger = options.logger;
44+
} else {
45+
this.logger = basicLogger({ level: 'info' });
46+
}
3747
}
3848

3949
/**
@@ -54,7 +64,11 @@ export default class LaunchDarklyProvider implements Provider {
5464
defaultValue: boolean,
5565
context: EvaluationContext,
5666
): Promise<ResolutionDetails<boolean>> {
57-
const res = await this.client.variationDetail(flagKey, translateContext(context), defaultValue);
67+
const res = await this.client.variationDetail(
68+
flagKey,
69+
this.translateContext(context),
70+
defaultValue,
71+
);
5872
if (typeof res.value === 'boolean') {
5973
return translateResult(res);
6074
}
@@ -79,7 +93,11 @@ export default class LaunchDarklyProvider implements Provider {
7993
defaultValue: string,
8094
context: EvaluationContext,
8195
): Promise<ResolutionDetails<string>> {
82-
const res = await this.client.variationDetail(flagKey, translateContext(context), defaultValue);
96+
const res = await this.client.variationDetail(
97+
flagKey,
98+
this.translateContext(context),
99+
defaultValue,
100+
);
83101
if (typeof res.value === 'string') {
84102
return translateResult(res);
85103
}
@@ -104,7 +122,11 @@ export default class LaunchDarklyProvider implements Provider {
104122
defaultValue: number,
105123
context: EvaluationContext,
106124
): Promise<ResolutionDetails<number>> {
107-
const res = await this.client.variationDetail(flagKey, translateContext(context), defaultValue);
125+
const res = await this.client.variationDetail(
126+
flagKey,
127+
this.translateContext(context),
128+
defaultValue,
129+
);
108130
if (typeof res.value === 'number') {
109131
return translateResult(res);
110132
}
@@ -127,7 +149,11 @@ export default class LaunchDarklyProvider implements Provider {
127149
defaultValue: U,
128150
context: EvaluationContext,
129151
): Promise<ResolutionDetails<U>> {
130-
const res = await this.client.variationDetail(flagKey, translateContext(context), defaultValue);
152+
const res = await this.client.variationDetail(
153+
flagKey,
154+
this.translateContext(context),
155+
defaultValue,
156+
);
131157
if (typeof res.value === 'object') {
132158
return translateResult(res);
133159
}
@@ -138,4 +164,8 @@ export default class LaunchDarklyProvider implements Provider {
138164
get hooks(): Hook<FlagValue>[] {
139165
return [];
140166
}
167+
168+
private translateContext(context: EvaluationContext) {
169+
return translateContext(this.logger, context);
170+
}
141171
}

src/LaunchDarklyProviderOptions.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { LDLogger } from 'launchdarkly-node-server-sdk';
2+
3+
/**
4+
* Options for the {@link LaunchDarklyProvider}.
5+
*/
6+
export interface LaunchDarklyProviderOptions {
7+
/**
8+
* Configures a logger for warnings and errors generated by the provider.
9+
*
10+
* The logger can be any object that conforms to the `LDLogger` interface.
11+
* For a simple implementation that lets you filter by log level, see
12+
* `basicLogger`. You can also use an instance of `winston.Logger` from
13+
* the Winston logging package.
14+
*
15+
* If you do not set this property, the provider uses `basicLogger` with a
16+
* minimum level of `info`.
17+
*
18+
* By default this will not be the same logger instance used by the SDK.
19+
* If the provider should share a logger with the SDK, then you will need to
20+
* provide the logger in the SDK options as well.
21+
*/
22+
logger?: LDLogger;
23+
}

0 commit comments

Comments
 (0)