Skip to content

Commit 9c4589b

Browse files
committed
feat: Add hook support.
1 parent 0422c50 commit 9c4589b

File tree

4 files changed

+249
-3
lines changed

4 files changed

+249
-3
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "launchdarkly-js-sdk-common",
3-
"version": "5.4.0",
3+
"version": "5.5.0-beta.1",
44
"description": "LaunchDarkly SDK for JavaScript - common code",
55
"author": "LaunchDarkly <[email protected]>",
66
"license": "Apache-2.0",

src/HookRunner.js

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
const UNKNOWN_HOOK_NAME = 'unknown hook';
2+
const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation';
3+
const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation';
4+
5+
function tryExecuteStage(logger, method, hookName, stage, def) {
6+
try {
7+
return stage();
8+
} catch (err) {
9+
logger?.error(`An error was encountered in "${method}" of the "${hookName}" hook: ${err}`);
10+
return def;
11+
}
12+
}
13+
14+
function getHookName(logger, hook) {
15+
try {
16+
return hook.getMetadata().name || UNKNOWN_HOOK_NAME;
17+
} catch {
18+
logger.error(`Exception thrown getting metadata for hook. Unable to get hook name.`);
19+
return UNKNOWN_HOOK_NAME;
20+
}
21+
}
22+
23+
function executeBeforeEvaluation(logger, hooks, hookContext) {
24+
return hooks.map(hook =>
25+
tryExecuteStage(
26+
logger,
27+
BEFORE_EVALUATION_STAGE_NAME,
28+
getHookName(logger, hook),
29+
() => hook?.beforeEvaluation?.(hookContext, {}) ?? {},
30+
{}
31+
)
32+
);
33+
}
34+
35+
function executeAfterEvaluation(logger, hooks, hookContext, updatedData, result) {
36+
// This iterates in reverse, versus reversing a shallow copy of the hooks,
37+
// for efficiency.
38+
for (let hookIndex = hooks.length - 1; hookIndex >= 0; hookIndex -= 1) {
39+
const hook = hooks[hookIndex];
40+
const data = updatedData[hookIndex];
41+
tryExecuteStage(
42+
logger,
43+
AFTER_EVALUATION_STAGE_NAME,
44+
getHookName(logger, hook),
45+
() => hook?.afterEvaluation?.(hookContext, data, result) ?? {},
46+
{}
47+
);
48+
}
49+
}
50+
51+
function executeBeforeIdentify(logger, hooks, hookContext) {
52+
return hooks.map(hook =>
53+
tryExecuteStage(
54+
logger,
55+
BEFORE_EVALUATION_STAGE_NAME,
56+
getHookName(logger, hook),
57+
() => hook?.beforeIdentify?.(hookContext, {}) ?? {},
58+
{}
59+
)
60+
);
61+
}
62+
63+
function executeAfterIdentify(logger, hooks, hookContext, updatedData, result) {
64+
// This iterates in reverse, versus reversing a shallow copy of the hooks,
65+
// for efficiency.
66+
for (let hookIndex = hooks.length - 1; hookIndex >= 0; hookIndex -= 1) {
67+
const hook = hooks[hookIndex];
68+
const data = updatedData[hookIndex];
69+
tryExecuteStage(
70+
logger,
71+
AFTER_EVALUATION_STAGE_NAME,
72+
getHookName(logger, hook),
73+
() => hook?.afterIdentify?.(hookContext, data, result) ?? {},
74+
{}
75+
);
76+
}
77+
}
78+
79+
class HookRunner {
80+
constructor(logger, initialHooks) {
81+
this._logger = logger;
82+
this._hooks = initialHooks ? [...initialHooks] : [];
83+
}
84+
85+
withEvaluation(key, context, defaultValue, method) {
86+
if (this._hooks.length === 0) {
87+
return method();
88+
}
89+
const hooks = [...this._hooks];
90+
const hookContext = {
91+
flagKey: key,
92+
context,
93+
defaultValue,
94+
};
95+
96+
const hookData = executeBeforeEvaluation(this._logger, hooks, hookContext);
97+
const result = method();
98+
executeAfterEvaluation(this._logger, hooks, hookContext, hookData, result);
99+
return result;
100+
}
101+
102+
identify(context, timeout) {
103+
const hooks = [...this._hooks];
104+
const hookContext = {
105+
context,
106+
timeout,
107+
};
108+
const hookData = executeBeforeIdentify(this._logger, hooks, hookContext);
109+
return result => {
110+
executeAfterIdentify(this._logger, hooks, hookContext, hookData, result);
111+
};
112+
}
113+
114+
addHook(hook) {
115+
this._hooks.push(hook);
116+
}
117+
}
118+
119+
module.exports = HookRunner;

src/index.js

+21-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const messages = require('./messages');
1717
const { checkContext, getContextKeys } = require('./context');
1818
const { InspectorTypes, InspectorManager } = require('./InspectorManager');
1919
const timedPromise = require('./timedPromise');
20+
const HookRunner = require('./HookRunner');
2021

2122
const changeEvent = 'change';
2223
const internalChangeEvent = 'internal-change';
@@ -40,6 +41,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
4041
const sendEvents = options.sendEvents;
4142
let environment = env;
4243
let hash = options.hash;
44+
const hookRunner = new HookRunner(logger, options.hooks);
4345

4446
const persistentStorage = PersistentStorage(platform.localStorage, logger);
4547

@@ -256,11 +258,16 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
256258
logger.warn(messages.identifyDisabled());
257259
return utils.wrapPromiseCallback(Promise.resolve(utils.transformVersionedValuesToValues(flags)), onDone);
258260
}
261+
let afterIdentify;
259262
const clearFirst = useLocalStorage && persistentFlagStore ? persistentFlagStore.clearFlags() : Promise.resolve();
260263
return utils.wrapPromiseCallback(
261264
clearFirst
262265
.then(() => anonymousContextProcessor.processContext(context))
263266
.then(verifyContext)
267+
.then(context => {
268+
afterIdentify = hookRunner.identify(context, undefined);
269+
return context;
270+
})
264271
.then(validatedContext =>
265272
requestor
266273
.fetchFlagSettings(validatedContext, newHash)
@@ -277,12 +284,14 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
277284
})
278285
)
279286
.then(flagValueMap => {
287+
afterIdentify?.({ status: 'completed' });
280288
if (streamActive) {
281289
connectStream();
282290
}
283291
return flagValueMap;
284292
})
285293
.catch(err => {
294+
afterIdentify?.({ status: 'error' });
286295
emitter.maybeReportError(err);
287296
return Promise.reject(err);
288297
}),
@@ -299,11 +308,16 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
299308
}
300309

301310
function variation(key, defaultValue) {
302-
return variationDetailInternal(key, defaultValue, true, false, false, true).value;
311+
const { value } = hookRunner.withEvaluation(key, ident.getContext(), defaultValue, () =>
312+
variationDetailInternal(key, defaultValue, true, false, false, true)
313+
);
314+
return value;
303315
}
304316

305317
function variationDetail(key, defaultValue) {
306-
return variationDetailInternal(key, defaultValue, true, true, false, true);
318+
return hookRunner.withEvaluation(key, ident.getContext(), defaultValue, () =>
319+
variationDetailInternal(key, defaultValue, true, true, false, true)
320+
);
307321
}
308322

309323
function variationDetailInternal(key, defaultValue, sendEvent, includeReasonInEvent, isAllFlags, notifyInspection) {
@@ -826,6 +840,10 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
826840
return initializationStateTracker.getInitializationPromise();
827841
}
828842

843+
function addHook(hook) {
844+
hookRunner.addHook(hook);
845+
}
846+
829847
const client = {
830848
waitForInitialization,
831849
waitUntilReady: () => initializationStateTracker.getReadyPromise(),
@@ -840,6 +858,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
840858
flush: flush,
841859
allFlags: allFlags,
842860
close: close,
861+
addHook: addHook,
843862
};
844863

845864
return {

typings.d.ts

+108
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,85 @@ declare module 'launchdarkly-js-sdk-common' {
4040
error: (message: string) => void;
4141
}
4242

43+
/**
44+
* Contextual information provided to evaluation stages.
45+
*/
46+
export interface EvaluationSeriesContext {
47+
readonly flagKey: string;
48+
readonly context: LDContext;
49+
readonly defaultValue: unknown;
50+
readonly method: string;
51+
}
52+
53+
/**
54+
* Implementation specific hook data for evaluation stages.
55+
*
56+
* Hook implementations can use this to store data needed between stages.
57+
*/
58+
export interface EvaluationSeriesData {
59+
readonly [index: string]: unknown;
60+
}
61+
62+
/**
63+
* Meta-data about a hook implementation.
64+
*/
65+
export interface HookMetadata {
66+
readonly name: string;
67+
}
68+
69+
/**
70+
* Interface for extending SDK functionality via hooks.
71+
*/
72+
export interface Hook {
73+
/**
74+
* Get metadata about the hook implementation.
75+
*/
76+
getMetadata(): HookMetadata;
77+
78+
/**
79+
* The before method is called during the execution of a variation method
80+
* before the flag value has been determined. The method is executed synchronously.
81+
*
82+
* @param hookContext Contains information about the evaluation being performed. This is not
83+
* mutable.
84+
* @param data A record associated with each stage of hook invocations. Each stage is called with
85+
* the data of the previous stage for a series. The input record should not be modified.
86+
* @returns Data to use when executing the next state of the hook in the evaluation series. It is
87+
* recommended to expand the previous input into the return. This helps ensure your stage remains
88+
* compatible moving forward as more stages are added.
89+
* ```js
90+
* return {...data, "my-new-field": /*my data/*}
91+
* ```
92+
*/
93+
beforeEvaluation?(
94+
hookContext: EvaluationSeriesContext,
95+
data: EvaluationSeriesData,
96+
): EvaluationSeriesData;
97+
98+
/**
99+
* The after method is called during the execution of the variation method
100+
* after the flag value has been determined. The method is executed synchronously.
101+
*
102+
* @param hookContext Contains read-only information about the evaluation
103+
* being performed.
104+
* @param data A record associated with each stage of hook invocations. Each
105+
* stage is called with the data of the previous stage for a series.
106+
* @param detail The result of the evaluation. This value should not be
107+
* modified.
108+
* @returns Data to use when executing the next state of the hook in the evaluation series. It is
109+
* recommended to expand the previous input into the return. This helps ensure your stage remains
110+
* compatible moving forward as more stages are added.
111+
* ```js
112+
* return {...data, "my-new-field": /*my data/*}
113+
* ```
114+
*/
115+
afterEvaluation?(
116+
hookContext: EvaluationSeriesContext,
117+
data: EvaluationSeriesData,
118+
detail: LDEvaluationDetail,
119+
): EvaluationSeriesData;
120+
}
121+
43122
/**
44123
* LaunchDarkly initialization options that are supported by all variants of the JS client.
45124
* The browser SDK and Electron SDK may support additional options.
@@ -277,6 +356,25 @@ declare module 'launchdarkly-js-sdk-common' {
277356
* Inspectors can be used for collecting information for monitoring, analytics, and debugging.
278357
*/
279358
inspectors?: LDInspection[];
359+
360+
/**
361+
* Initial set of hooks for the client.
362+
*
363+
* Hooks provide entrypoints which allow for observation of SDK functions.
364+
*
365+
* LaunchDarkly provides integration packages, and most applications will not
366+
* need to implement their own hooks. Refer to the `@launchdarkly/node-server-sdk-otel`
367+
* for instrumentation for the `@launchdarkly/node-server-sdk`.
368+
*
369+
* Example:
370+
* ```typescript
371+
* import { init } from '@launchdarkly/node-server-sdk';
372+
* import { TheHook } from '@launchdarkly/some-hook';
373+
*
374+
* const client = init('my-sdk-key', { hooks: [new TheHook()] });
375+
* ```
376+
*/
377+
hooks?: Hook[];
280378
}
281379

282380
/**
@@ -909,6 +1007,16 @@ declare module 'launchdarkly-js-sdk-common' {
9091007
* closing is finished. It will never be rejected.
9101008
*/
9111009
close(onDone?: () => void): Promise<void>;
1010+
1011+
/**
1012+
* Add a hook to the client. In order to register a hook before the client
1013+
* starts, please use the `hooks` property of {@link LDOptions}.
1014+
*
1015+
* Hooks provide entrypoints which allow for observation of SDK functions.
1016+
*
1017+
* @param Hook The hook to add.
1018+
*/
1019+
addHook(hook: Hook): void;
9121020
}
9131021

9141022
/**

0 commit comments

Comments
 (0)