posthog-node 2.0.0-alpha9 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +0 -2
- package/lib/index.cjs.js +857 -60
- package/lib/index.cjs.js.map +1 -1
- package/lib/index.d.ts +68 -9
- package/lib/index.esm.js +858 -61
- package/lib/index.esm.js.map +1 -1
- package/lib/posthog-core/src/index.d.ts +19 -6
- package/lib/posthog-core/src/types.d.ts +3 -1
- package/lib/posthog-node/src/feature-flags.d.ts +48 -0
- package/lib/posthog-node/src/posthog-node.d.ts +29 -3
- package/lib/posthog-node/src/types.d.ts +69 -6
- package/package.json +1 -1
- package/src/feature-flags.ts +396 -0
- package/src/posthog-node.ts +180 -25
- package/src/types.ts +72 -8
- package/test/feature-flags.spec.ts +3192 -0
- package/test/posthog-node.spec.ts +300 -24
|
@@ -5,7 +5,7 @@ import { LZString } from './lz-string';
|
|
|
5
5
|
import { SimpleEventEmitter } from './eventemitter';
|
|
6
6
|
export declare abstract class PostHogCore {
|
|
7
7
|
private apiKey;
|
|
8
|
-
|
|
8
|
+
host: string;
|
|
9
9
|
private flushAt;
|
|
10
10
|
private flushInterval;
|
|
11
11
|
private captureMode;
|
|
@@ -28,12 +28,13 @@ export declare abstract class PostHogCore {
|
|
|
28
28
|
protected getCommonEventProperties(): any;
|
|
29
29
|
private get props();
|
|
30
30
|
private set props(value);
|
|
31
|
+
private clearProps;
|
|
31
32
|
private _props;
|
|
32
33
|
get optedOut(): boolean;
|
|
33
34
|
optIn(): void;
|
|
34
35
|
optOut(): void;
|
|
35
36
|
on(event: string, cb: (...args: any[]) => void): () => void;
|
|
36
|
-
reset(): void;
|
|
37
|
+
reset(propertiesToKeep?: PostHogPersistedProperty[]): void;
|
|
37
38
|
debug(enabled?: boolean): void;
|
|
38
39
|
private buildPayload;
|
|
39
40
|
getSessionId(): string | undefined;
|
|
@@ -50,7 +51,7 @@ export declare abstract class PostHogCore {
|
|
|
50
51
|
identify(distinctId?: string, properties?: PostHogEventProperties): this;
|
|
51
52
|
capture(event: string, properties?: {
|
|
52
53
|
[key: string]: any;
|
|
53
|
-
}): this;
|
|
54
|
+
}, forceSendFeatureFlags?: boolean): this;
|
|
54
55
|
alias(alias: string): this;
|
|
55
56
|
autocapture(eventType: string, elements: PostHogAutocaptureElement[], properties?: PostHogEventProperties): this;
|
|
56
57
|
/***
|
|
@@ -61,18 +62,30 @@ export declare abstract class PostHogCore {
|
|
|
61
62
|
}): this;
|
|
62
63
|
group(groupType: string, groupKey: string | number, groupProperties?: PostHogEventProperties): this;
|
|
63
64
|
groupIdentify(groupType: string, groupKey: string | number, groupProperties?: PostHogEventProperties): this;
|
|
65
|
+
/***
|
|
66
|
+
* PROPERTIES
|
|
67
|
+
***/
|
|
68
|
+
personProperties(properties: {
|
|
69
|
+
[type: string]: string;
|
|
70
|
+
}): this;
|
|
71
|
+
groupProperties(properties: {
|
|
72
|
+
[type: string]: Record<string, string>;
|
|
73
|
+
}): this;
|
|
64
74
|
/***
|
|
65
75
|
*** FEATURE FLAGS
|
|
66
76
|
***/
|
|
67
77
|
private decideAsync;
|
|
68
78
|
private _decideAsync;
|
|
69
|
-
getFeatureFlag(key: string
|
|
79
|
+
getFeatureFlag(key: string): boolean | string | undefined;
|
|
70
80
|
getFeatureFlags(): PostHogDecideResponse['featureFlags'] | undefined;
|
|
71
|
-
isFeatureEnabled(key: string
|
|
72
|
-
reloadFeatureFlagsAsync(): Promise<PostHogDecideResponse['featureFlags']>;
|
|
81
|
+
isFeatureEnabled(key: string): boolean | undefined;
|
|
82
|
+
reloadFeatureFlagsAsync(sendAnonDistinctId?: boolean): Promise<PostHogDecideResponse['featureFlags']>;
|
|
73
83
|
onFeatureFlags(cb: (flags: PostHogDecideResponse['featureFlags']) => void): () => void;
|
|
74
84
|
onFeatureFlag(key: string, cb: (value: string | boolean) => void): () => void;
|
|
75
85
|
overrideFeatureFlag(flags: PostHogDecideResponse['featureFlags'] | null): void;
|
|
86
|
+
_sendFeatureFlags(event: string, properties?: {
|
|
87
|
+
[key: string]: any;
|
|
88
|
+
}): void;
|
|
76
89
|
/***
|
|
77
90
|
*** QUEUEING AND FLUSHING
|
|
78
91
|
***/
|
|
@@ -19,7 +19,9 @@ export declare enum PostHogPersistedProperty {
|
|
|
19
19
|
Queue = "queue",
|
|
20
20
|
OptedOut = "opted_out",
|
|
21
21
|
SessionId = "session_id",
|
|
22
|
-
SessionLastTimestamp = "session_timestamp"
|
|
22
|
+
SessionLastTimestamp = "session_timestamp",
|
|
23
|
+
PersonProperties = "person_properties",
|
|
24
|
+
GroupProperties = "group_properties"
|
|
23
25
|
}
|
|
24
26
|
export declare type PostHogFetchOptions = {
|
|
25
27
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { FeatureFlagCondition, PostHogFeatureFlag } from './types';
|
|
3
|
+
import { ResponseData } from 'undici/types/dispatcher';
|
|
4
|
+
declare class ClientError extends Error {
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
7
|
+
declare class InconclusiveMatchError extends Error {
|
|
8
|
+
constructor(message: string);
|
|
9
|
+
}
|
|
10
|
+
declare type FeatureFlagsPollerOptions = {
|
|
11
|
+
personalApiKey: string;
|
|
12
|
+
projectApiKey: string;
|
|
13
|
+
host: string;
|
|
14
|
+
pollingInterval: number;
|
|
15
|
+
timeout?: number;
|
|
16
|
+
};
|
|
17
|
+
declare class FeatureFlagsPoller {
|
|
18
|
+
pollingInterval: number;
|
|
19
|
+
personalApiKey: string;
|
|
20
|
+
projectApiKey: string;
|
|
21
|
+
featureFlags: Array<PostHogFeatureFlag>;
|
|
22
|
+
groupTypeMapping: Record<string, string>;
|
|
23
|
+
loadedSuccessfullyOnce: boolean;
|
|
24
|
+
timeout?: number;
|
|
25
|
+
host: FeatureFlagsPollerOptions['host'];
|
|
26
|
+
poller?: NodeJS.Timeout;
|
|
27
|
+
constructor({ pollingInterval, personalApiKey, projectApiKey, timeout, host }: FeatureFlagsPollerOptions);
|
|
28
|
+
getFeatureFlag(key: string, distinctId: string, groups?: Record<string, string>, personProperties?: Record<string, string>, groupProperties?: Record<string, Record<string, string>>): Promise<string | boolean | undefined>;
|
|
29
|
+
getAllFlags(distinctId: string, groups?: Record<string, string>, personProperties?: Record<string, string>, groupProperties?: Record<string, Record<string, string>>): Promise<{
|
|
30
|
+
response: Record<string, string | boolean>;
|
|
31
|
+
fallbackToDecide: boolean;
|
|
32
|
+
}>;
|
|
33
|
+
computeFlagLocally(flag: PostHogFeatureFlag, distinctId: string, groups?: Record<string, string>, personProperties?: Record<string, string>, groupProperties?: Record<string, Record<string, string>>): string | boolean;
|
|
34
|
+
matchFeatureFlagProperties(flag: PostHogFeatureFlag, distinctId: string, properties: Record<string, string>): string | boolean;
|
|
35
|
+
isConditionMatch(flag: PostHogFeatureFlag, distinctId: string, condition: FeatureFlagCondition, properties: Record<string, string>): boolean;
|
|
36
|
+
getMatchingVariant(flag: PostHogFeatureFlag, distinctId: string): string | boolean | undefined;
|
|
37
|
+
variantLookupTable(flag: PostHogFeatureFlag): {
|
|
38
|
+
valueMin: number;
|
|
39
|
+
valueMax: number;
|
|
40
|
+
key: string;
|
|
41
|
+
}[];
|
|
42
|
+
loadFeatureFlags(forceReload?: boolean): Promise<void>;
|
|
43
|
+
_loadFeatureFlags(): Promise<void>;
|
|
44
|
+
_requestFeatureFlagDefinitions(): Promise<ResponseData>;
|
|
45
|
+
stopPoller(): void;
|
|
46
|
+
}
|
|
47
|
+
declare function matchProperty(property: FeatureFlagCondition['properties'][number], propertyValues: Record<string, any>): boolean;
|
|
48
|
+
export { FeatureFlagsPoller, matchProperty, InconclusiveMatchError, ClientError };
|
|
@@ -2,23 +2,49 @@ import { PosthogCoreOptions } from '../../posthog-core/src';
|
|
|
2
2
|
import { EventMessageV1, GroupIdentifyMessage, IdentifyMessageV1, PostHogNodeV1 } from './types';
|
|
3
3
|
export declare type PostHogOptions = PosthogCoreOptions & {
|
|
4
4
|
persistence?: 'memory';
|
|
5
|
+
personalApiKey?: string;
|
|
6
|
+
featureFlagsPollingInterval?: number;
|
|
7
|
+
requestTimeout?: number;
|
|
8
|
+
maxCacheSize?: number;
|
|
5
9
|
};
|
|
6
10
|
export declare class PostHogGlobal implements PostHogNodeV1 {
|
|
7
11
|
private _sharedClient;
|
|
12
|
+
private featureFlagsPoller?;
|
|
13
|
+
private maxCacheSize;
|
|
14
|
+
distinctIdHasSentFlagCalls: Record<string, string[]>;
|
|
8
15
|
constructor(apiKey: string, options?: PostHogOptions);
|
|
9
16
|
private reInit;
|
|
10
17
|
enable(): void;
|
|
11
18
|
disable(): void;
|
|
12
|
-
capture({ distinctId, event, properties, groups }: EventMessageV1): void;
|
|
19
|
+
capture({ distinctId, event, properties, groups, sendFeatureFlags }: EventMessageV1): void;
|
|
13
20
|
identify({ distinctId, properties }: IdentifyMessageV1): void;
|
|
14
21
|
alias(data: {
|
|
15
22
|
distinctId: string;
|
|
16
23
|
alias: string;
|
|
17
24
|
}): void;
|
|
18
|
-
getFeatureFlag(key: string, distinctId: string,
|
|
19
|
-
|
|
25
|
+
getFeatureFlag(key: string, distinctId: string, options?: {
|
|
26
|
+
groups?: Record<string, string>;
|
|
27
|
+
personProperties?: Record<string, string>;
|
|
28
|
+
groupProperties?: Record<string, Record<string, string>>;
|
|
29
|
+
onlyEvaluateLocally?: boolean;
|
|
30
|
+
sendFeatureFlagEvents?: boolean;
|
|
31
|
+
}): Promise<string | boolean | undefined>;
|
|
32
|
+
isFeatureEnabled(key: string, distinctId: string, options?: {
|
|
33
|
+
groups?: Record<string, string>;
|
|
34
|
+
personProperties?: Record<string, string>;
|
|
35
|
+
groupProperties?: Record<string, Record<string, string>>;
|
|
36
|
+
onlyEvaluateLocally?: boolean;
|
|
37
|
+
sendFeatureFlagEvents?: boolean;
|
|
38
|
+
}): Promise<boolean | undefined>;
|
|
39
|
+
getAllFlags(distinctId: string, options?: {
|
|
40
|
+
groups?: Record<string, string>;
|
|
41
|
+
personProperties?: Record<string, string>;
|
|
42
|
+
groupProperties?: Record<string, Record<string, string>>;
|
|
43
|
+
onlyEvaluateLocally?: boolean;
|
|
44
|
+
}): Promise<Record<string, string | boolean>>;
|
|
20
45
|
groupIdentify({ groupType, groupKey, properties }: GroupIdentifyMessage): void;
|
|
21
46
|
reloadFeatureFlags(): Promise<void>;
|
|
47
|
+
flush(): void;
|
|
22
48
|
shutdown(): void;
|
|
23
49
|
shutdownAsync(): Promise<void>;
|
|
24
50
|
debug(enabled?: boolean): void;
|
|
@@ -12,6 +12,36 @@ export interface GroupIdentifyMessage {
|
|
|
12
12
|
groupKey: string;
|
|
13
13
|
properties?: Record<string | number, any>;
|
|
14
14
|
}
|
|
15
|
+
export declare type FeatureFlagCondition = {
|
|
16
|
+
properties: {
|
|
17
|
+
key: string;
|
|
18
|
+
type?: string;
|
|
19
|
+
value: string | number | (string | number)[];
|
|
20
|
+
operator?: string;
|
|
21
|
+
}[];
|
|
22
|
+
rollout_percentage?: number;
|
|
23
|
+
};
|
|
24
|
+
export declare type PostHogFeatureFlag = {
|
|
25
|
+
id: number;
|
|
26
|
+
name: string;
|
|
27
|
+
key: string;
|
|
28
|
+
filters?: {
|
|
29
|
+
aggregation_group_type_index?: number;
|
|
30
|
+
groups?: FeatureFlagCondition[];
|
|
31
|
+
multivariate?: {
|
|
32
|
+
variants: {
|
|
33
|
+
key: string;
|
|
34
|
+
rollout_percentage: number;
|
|
35
|
+
}[];
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
deleted: boolean;
|
|
39
|
+
active: boolean;
|
|
40
|
+
is_simple_flag: boolean;
|
|
41
|
+
rollout_percentage: null | number;
|
|
42
|
+
ensure_experience_continuity: boolean;
|
|
43
|
+
experiment_set: number[];
|
|
44
|
+
};
|
|
15
45
|
export declare type PostHogNodeV1 = {
|
|
16
46
|
/**
|
|
17
47
|
* @description Capture allows you to capture anything a user does within your system,
|
|
@@ -22,7 +52,7 @@ export declare type PostHogNodeV1 = {
|
|
|
22
52
|
* @param event We recommend using [verb] [noun], like movie played or movie updated to easily identify what your events mean later on.
|
|
23
53
|
* @param properties OPTIONAL | which can be a object with any information you'd like to add
|
|
24
54
|
* @param groups OPTIONAL | object of what groups are related to this event, example: { company: 'id:5' }. Can be used to analyze companies instead of users.
|
|
25
|
-
* @param sendFeatureFlags OPTIONAL | Used with experiments
|
|
55
|
+
* @param sendFeatureFlags OPTIONAL | Used with experiments. Determines whether to send feature flag values with the event.
|
|
26
56
|
*/
|
|
27
57
|
capture({ distinctId, event, properties, groups, sendFeatureFlags }: EventMessageV1): void;
|
|
28
58
|
/**
|
|
@@ -53,14 +83,47 @@ export declare type PostHogNodeV1 = {
|
|
|
53
83
|
* allow you to safely deploy and roll back new features. Once you've created a feature flag in PostHog,
|
|
54
84
|
* you can use this method to check if the flag is on for a given user, allowing you to create logic to turn
|
|
55
85
|
* features on and off for different user groups or individual users.
|
|
56
|
-
* IMPORTANT: To use this method, you need to specify `personalApiKey` in your config! More info: https://posthog.com/docs/api/overview
|
|
57
86
|
* @param key the unique key of your feature flag
|
|
58
87
|
* @param distinctId the current unique id
|
|
59
|
-
* @param
|
|
60
|
-
* @param groups optional - what groups are currently active (group analytics)
|
|
88
|
+
* @param options: dict with optional parameters below
|
|
89
|
+
* @param groups optional - what groups are currently active (group analytics). Required if the flag depends on groups.
|
|
90
|
+
* @param personProperties optional - what person properties are known. Used to compute flags locally, if personalApiKey is present.
|
|
91
|
+
* @param groupProperties optional - what group properties are known. Used to compute flags locally, if personalApiKey is present.
|
|
92
|
+
* @param onlyEvaluateLocally optional - whether to only evaluate the flag locally. Defaults to false.
|
|
93
|
+
* @param sendFeatureFlagEvents optional - whether to send feature flag events. Used for Experiments. Defaults to true.
|
|
94
|
+
*
|
|
95
|
+
* @returns true if the flag is on, false if the flag is off, undefined if there was an error.
|
|
96
|
+
*/
|
|
97
|
+
isFeatureEnabled(key: string, distinctId: string, options?: {
|
|
98
|
+
groups?: Record<string, string>;
|
|
99
|
+
personProperties?: Record<string, string>;
|
|
100
|
+
groupProperties?: Record<string, Record<string, string>>;
|
|
101
|
+
onlyEvaluateLocally?: boolean;
|
|
102
|
+
sendFeatureFlagEvents?: boolean;
|
|
103
|
+
}): Promise<boolean | undefined>;
|
|
104
|
+
/**
|
|
105
|
+
* @description PostHog feature flags (https://posthog.com/docs/features/feature-flags)
|
|
106
|
+
* allow you to safely deploy and roll back new features. Once you've created a feature flag in PostHog,
|
|
107
|
+
* you can use this method to check if the flag is on for a given user, allowing you to create logic to turn
|
|
108
|
+
* features on and off for different user groups or individual users.
|
|
109
|
+
* @param key the unique key of your feature flag
|
|
110
|
+
* @param distinctId the current unique id
|
|
111
|
+
* @param options: dict with optional parameters below
|
|
112
|
+
* @param groups optional - what groups are currently active (group analytics). Required if the flag depends on groups.
|
|
113
|
+
* @param personProperties optional - what person properties are known. Used to compute flags locally, if personalApiKey is present.
|
|
114
|
+
* @param groupProperties optional - what group properties are known. Used to compute flags locally, if personalApiKey is present.
|
|
115
|
+
* @param onlyEvaluateLocally optional - whether to only evaluate the flag locally. Defaults to false.
|
|
116
|
+
* @param sendFeatureFlagEvents optional - whether to send feature flag events. Used for Experiments. Defaults to true.
|
|
117
|
+
*
|
|
118
|
+
* @returns true or string(for multivariates) if the flag is on, false if the flag is off, undefined if there was an error.
|
|
61
119
|
*/
|
|
62
|
-
|
|
63
|
-
|
|
120
|
+
getFeatureFlag(key: string, distinctId: string, options?: {
|
|
121
|
+
groups?: Record<string, string>;
|
|
122
|
+
personProperties?: Record<string, string>;
|
|
123
|
+
groupProperties?: Record<string, Record<string, string>>;
|
|
124
|
+
onlyEvaluateLocally?: boolean;
|
|
125
|
+
sendFeatureFlagEvents?: boolean;
|
|
126
|
+
}): Promise<string | boolean | undefined>;
|
|
64
127
|
/**
|
|
65
128
|
* @description Sets a groups properties, which allows asking questions like "Who are the most active companies"
|
|
66
129
|
* using my product in PostHog.
|
package/package.json
CHANGED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { createHash } from 'crypto'
|
|
2
|
+
import { request } from 'undici'
|
|
3
|
+
import { FeatureFlagCondition, PostHogFeatureFlag } from './types'
|
|
4
|
+
import { version } from '../package.json'
|
|
5
|
+
import { ResponseData } from 'undici/types/dispatcher'
|
|
6
|
+
|
|
7
|
+
// eslint-disable-next-line
|
|
8
|
+
const LONG_SCALE = 0xfffffffffffffff
|
|
9
|
+
|
|
10
|
+
class ClientError extends Error {
|
|
11
|
+
constructor(message: string) {
|
|
12
|
+
super()
|
|
13
|
+
Error.captureStackTrace(this, this.constructor)
|
|
14
|
+
this.name = 'ClientError'
|
|
15
|
+
this.message = message
|
|
16
|
+
Object.setPrototypeOf(this, ClientError.prototype)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class InconclusiveMatchError extends Error {
|
|
21
|
+
constructor(message: string) {
|
|
22
|
+
super(message)
|
|
23
|
+
this.name = this.constructor.name
|
|
24
|
+
Error.captureStackTrace(this, this.constructor)
|
|
25
|
+
// instanceof doesn't work in ES3 or ES5
|
|
26
|
+
// https://www.dannyguo.com/blog/how-to-fix-instanceof-not-working-for-custom-errors-in-typescript/
|
|
27
|
+
// this is the workaround
|
|
28
|
+
Object.setPrototypeOf(this, InconclusiveMatchError.prototype)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type FeatureFlagsPollerOptions = {
|
|
33
|
+
personalApiKey: string
|
|
34
|
+
projectApiKey: string
|
|
35
|
+
host: string
|
|
36
|
+
pollingInterval: number
|
|
37
|
+
timeout?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class FeatureFlagsPoller {
|
|
41
|
+
pollingInterval: number
|
|
42
|
+
personalApiKey: string
|
|
43
|
+
projectApiKey: string
|
|
44
|
+
featureFlags: Array<PostHogFeatureFlag>
|
|
45
|
+
groupTypeMapping: Record<string, string>
|
|
46
|
+
loadedSuccessfullyOnce: boolean
|
|
47
|
+
timeout?: number
|
|
48
|
+
host: FeatureFlagsPollerOptions['host']
|
|
49
|
+
poller?: NodeJS.Timeout
|
|
50
|
+
|
|
51
|
+
constructor({ pollingInterval, personalApiKey, projectApiKey, timeout, host }: FeatureFlagsPollerOptions) {
|
|
52
|
+
this.pollingInterval = pollingInterval
|
|
53
|
+
this.personalApiKey = personalApiKey
|
|
54
|
+
this.featureFlags = []
|
|
55
|
+
this.groupTypeMapping = {}
|
|
56
|
+
this.loadedSuccessfullyOnce = false
|
|
57
|
+
this.timeout = timeout
|
|
58
|
+
this.projectApiKey = projectApiKey
|
|
59
|
+
this.host = host
|
|
60
|
+
this.poller = undefined
|
|
61
|
+
|
|
62
|
+
void this.loadFeatureFlags()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async getFeatureFlag(
|
|
66
|
+
key: string,
|
|
67
|
+
distinctId: string,
|
|
68
|
+
groups: Record<string, string> = {},
|
|
69
|
+
personProperties: Record<string, string> = {},
|
|
70
|
+
groupProperties: Record<string, Record<string, string>> = {}
|
|
71
|
+
): Promise<string | boolean | undefined> {
|
|
72
|
+
await this.loadFeatureFlags()
|
|
73
|
+
|
|
74
|
+
let response = undefined
|
|
75
|
+
let featureFlag = undefined
|
|
76
|
+
|
|
77
|
+
if (!this.loadedSuccessfullyOnce) {
|
|
78
|
+
return response
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const flag of this.featureFlags) {
|
|
82
|
+
if (key === flag.key) {
|
|
83
|
+
featureFlag = flag
|
|
84
|
+
break
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (featureFlag !== undefined) {
|
|
89
|
+
try {
|
|
90
|
+
response = this.computeFlagLocally(featureFlag, distinctId, groups, personProperties, groupProperties)
|
|
91
|
+
console.debug(`Successfully computed flag locally: ${key} -> ${response}`)
|
|
92
|
+
} catch (e) {
|
|
93
|
+
if (e instanceof InconclusiveMatchError) {
|
|
94
|
+
console.debug(`Can't compute flag locally: ${key}: ${e}`)
|
|
95
|
+
} else if (e instanceof Error) {
|
|
96
|
+
console.error(`Error computing flag locally: ${key}: ${e}`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return response
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async getAllFlags(
|
|
105
|
+
distinctId: string,
|
|
106
|
+
groups: Record<string, string> = {},
|
|
107
|
+
personProperties: Record<string, string> = {},
|
|
108
|
+
groupProperties: Record<string, Record<string, string>> = {}
|
|
109
|
+
): Promise<{ response: Record<string, string | boolean>; fallbackToDecide: boolean }> {
|
|
110
|
+
await this.loadFeatureFlags()
|
|
111
|
+
|
|
112
|
+
const response: Record<string, string | boolean> = {}
|
|
113
|
+
let fallbackToDecide = this.featureFlags.length == 0
|
|
114
|
+
|
|
115
|
+
this.featureFlags.map((flag) => {
|
|
116
|
+
try {
|
|
117
|
+
response[flag.key] = this.computeFlagLocally(flag, distinctId, groups, personProperties, groupProperties)
|
|
118
|
+
} catch (e) {
|
|
119
|
+
if (e instanceof InconclusiveMatchError) {
|
|
120
|
+
// do nothing
|
|
121
|
+
} else if (e instanceof Error) {
|
|
122
|
+
console.error(`Error computing flag locally: ${flag.key}: ${e}`)
|
|
123
|
+
}
|
|
124
|
+
fallbackToDecide = true
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
return { response, fallbackToDecide }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
computeFlagLocally(
|
|
132
|
+
flag: PostHogFeatureFlag,
|
|
133
|
+
distinctId: string,
|
|
134
|
+
groups: Record<string, string> = {},
|
|
135
|
+
personProperties: Record<string, string> = {},
|
|
136
|
+
groupProperties: Record<string, Record<string, string>> = {}
|
|
137
|
+
): string | boolean {
|
|
138
|
+
if (flag.ensure_experience_continuity) {
|
|
139
|
+
throw new InconclusiveMatchError('Flag has experience continuity enabled')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!flag.active) {
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const flagFilters = flag.filters || {}
|
|
147
|
+
const aggregation_group_type_index = flagFilters.aggregation_group_type_index
|
|
148
|
+
|
|
149
|
+
if (aggregation_group_type_index != undefined) {
|
|
150
|
+
const groupName = this.groupTypeMapping[String(aggregation_group_type_index)]
|
|
151
|
+
|
|
152
|
+
if (!groupName) {
|
|
153
|
+
console.warn(
|
|
154
|
+
`[FEATURE FLAGS] Unknown group type index ${aggregation_group_type_index} for feature flag ${flag.key}`
|
|
155
|
+
)
|
|
156
|
+
throw new InconclusiveMatchError('Flag has unknown group type index')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!(groupName in groups)) {
|
|
160
|
+
console.warn(`[FEATURE FLAGS] Can't compute group feature flag: ${flag.key} without group names passed in`)
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const focusedGroupProperties = groupProperties[groupName]
|
|
165
|
+
return this.matchFeatureFlagProperties(flag, groups[groupName], focusedGroupProperties)
|
|
166
|
+
} else {
|
|
167
|
+
return this.matchFeatureFlagProperties(flag, distinctId, personProperties)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
matchFeatureFlagProperties(
|
|
172
|
+
flag: PostHogFeatureFlag,
|
|
173
|
+
distinctId: string,
|
|
174
|
+
properties: Record<string, string>
|
|
175
|
+
): string | boolean {
|
|
176
|
+
const flagFilters = flag.filters || {}
|
|
177
|
+
const flagConditions = flagFilters.groups || []
|
|
178
|
+
let isInconclusive = false
|
|
179
|
+
let result = undefined
|
|
180
|
+
|
|
181
|
+
flagConditions.forEach((condition) => {
|
|
182
|
+
try {
|
|
183
|
+
if (this.isConditionMatch(flag, distinctId, condition, properties)) {
|
|
184
|
+
result = this.getMatchingVariant(flag, distinctId) || true
|
|
185
|
+
}
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if (e instanceof InconclusiveMatchError) {
|
|
188
|
+
isInconclusive = true
|
|
189
|
+
} else {
|
|
190
|
+
throw e
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
if (result !== undefined) {
|
|
196
|
+
return result
|
|
197
|
+
} else if (isInconclusive) {
|
|
198
|
+
throw new InconclusiveMatchError("Can't determine if feature flag is enabled or not with given properties")
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// We can only return False when all conditions are False
|
|
202
|
+
return false
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
isConditionMatch(
|
|
206
|
+
flag: PostHogFeatureFlag,
|
|
207
|
+
distinctId: string,
|
|
208
|
+
condition: FeatureFlagCondition,
|
|
209
|
+
properties: Record<string, string>
|
|
210
|
+
): boolean {
|
|
211
|
+
const rolloutPercentage = condition.rollout_percentage
|
|
212
|
+
|
|
213
|
+
if ((condition.properties || []).length > 0) {
|
|
214
|
+
const matchAll = condition.properties.every((property) => {
|
|
215
|
+
return matchProperty(property, properties)
|
|
216
|
+
})
|
|
217
|
+
if (!matchAll) {
|
|
218
|
+
return false
|
|
219
|
+
} else if (rolloutPercentage == undefined) {
|
|
220
|
+
// == to include `null` as a match, not just `undefined`
|
|
221
|
+
return true
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (rolloutPercentage != undefined && _hash(flag.key, distinctId) > rolloutPercentage / 100.0) {
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return true
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
getMatchingVariant(flag: PostHogFeatureFlag, distinctId: string): string | boolean | undefined {
|
|
233
|
+
const hashValue = _hash(flag.key, distinctId, 'variant')
|
|
234
|
+
const matchingVariant = this.variantLookupTable(flag).find((variant) => {
|
|
235
|
+
return hashValue >= variant.valueMin && hashValue < variant.valueMax
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
if (matchingVariant) {
|
|
239
|
+
return matchingVariant.key
|
|
240
|
+
}
|
|
241
|
+
return undefined
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
variantLookupTable(flag: PostHogFeatureFlag): { valueMin: number; valueMax: number; key: string }[] {
|
|
245
|
+
const lookupTable: { valueMin: number; valueMax: number; key: string }[] = []
|
|
246
|
+
let valueMin = 0
|
|
247
|
+
let valueMax = 0
|
|
248
|
+
const flagFilters = flag.filters || {}
|
|
249
|
+
const multivariates: {
|
|
250
|
+
key: string
|
|
251
|
+
rollout_percentage: number
|
|
252
|
+
}[] = flagFilters.multivariate?.variants || []
|
|
253
|
+
|
|
254
|
+
multivariates.forEach((variant) => {
|
|
255
|
+
valueMax = valueMin + variant.rollout_percentage / 100.0
|
|
256
|
+
lookupTable.push({ valueMin, valueMax, key: variant.key })
|
|
257
|
+
valueMin = valueMax
|
|
258
|
+
})
|
|
259
|
+
return lookupTable
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async loadFeatureFlags(forceReload = false): Promise<void> {
|
|
263
|
+
if (!this.loadedSuccessfullyOnce || forceReload) {
|
|
264
|
+
await this._loadFeatureFlags()
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async _loadFeatureFlags(): Promise<void> {
|
|
269
|
+
if (this.poller) {
|
|
270
|
+
clearTimeout(this.poller)
|
|
271
|
+
this.poller = undefined
|
|
272
|
+
}
|
|
273
|
+
this.poller = setTimeout(() => this._loadFeatureFlags(), this.pollingInterval)
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const res = await this._requestFeatureFlagDefinitions()
|
|
277
|
+
|
|
278
|
+
if (res && res.statusCode === 401) {
|
|
279
|
+
throw new ClientError(
|
|
280
|
+
`Your personalApiKey is invalid. Are you sure you're not using your Project API key? More information: https://posthog.com/docs/api/overview`
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
const responseJson = await res.body.json()
|
|
284
|
+
if (!('flags' in responseJson)) {
|
|
285
|
+
console.error(`Invalid response when getting feature flags: ${JSON.stringify(responseJson)}`)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this.featureFlags = responseJson.flags || []
|
|
289
|
+
this.groupTypeMapping = responseJson.group_type_mapping || {}
|
|
290
|
+
this.loadedSuccessfullyOnce = true
|
|
291
|
+
} catch (err) {
|
|
292
|
+
// if an error that is not an instance of ClientError is thrown
|
|
293
|
+
// we silently ignore the error when reloading feature flags
|
|
294
|
+
if (err instanceof ClientError) {
|
|
295
|
+
throw err
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async _requestFeatureFlagDefinitions(): Promise<ResponseData> {
|
|
301
|
+
const url = `${this.host}/api/feature_flag/local_evaluation?token=${this.projectApiKey}`
|
|
302
|
+
const headers = {
|
|
303
|
+
'Content-Type': 'application/json',
|
|
304
|
+
Authorization: `Bearer ${this.personalApiKey}`,
|
|
305
|
+
'user-agent': `posthog-node/${version}`,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const options: Parameters<typeof request>[1] = {
|
|
309
|
+
method: 'GET',
|
|
310
|
+
headers: headers,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (this.timeout && typeof this.timeout === 'number') {
|
|
314
|
+
options.bodyTimeout = this.timeout
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let res
|
|
318
|
+
try {
|
|
319
|
+
res = await request(url, options)
|
|
320
|
+
} catch (err) {
|
|
321
|
+
throw new Error(`Request failed with error: ${err}`)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return res
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
stopPoller(): void {
|
|
328
|
+
clearTimeout(this.poller)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// # This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
|
|
333
|
+
// # Given the same distinct_id and key, it'll always return the same float. These floats are
|
|
334
|
+
// # uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
|
|
335
|
+
// # we can do _hash(key, distinct_id) < 0.2
|
|
336
|
+
function _hash(key: string, distinctId: string, salt: string = ''): number {
|
|
337
|
+
const sha1Hash = createHash('sha1')
|
|
338
|
+
sha1Hash.update(`${key}.${distinctId}${salt}`)
|
|
339
|
+
return parseInt(sha1Hash.digest('hex').slice(0, 15), 16) / LONG_SCALE
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function matchProperty(
|
|
343
|
+
property: FeatureFlagCondition['properties'][number],
|
|
344
|
+
propertyValues: Record<string, any>
|
|
345
|
+
): boolean {
|
|
346
|
+
const key = property.key
|
|
347
|
+
const value = property.value
|
|
348
|
+
const operator = property.operator || 'exact'
|
|
349
|
+
|
|
350
|
+
if (!(key in propertyValues)) {
|
|
351
|
+
throw new InconclusiveMatchError(`Property ${key} not found in propertyValues`)
|
|
352
|
+
} else if (operator === 'is_not_set') {
|
|
353
|
+
throw new InconclusiveMatchError(`Operator is_not_set is not supported`)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const overrideValue = propertyValues[key]
|
|
357
|
+
|
|
358
|
+
switch (operator) {
|
|
359
|
+
case 'exact':
|
|
360
|
+
return Array.isArray(value) ? value.indexOf(overrideValue) !== -1 : value === overrideValue
|
|
361
|
+
case 'is_not':
|
|
362
|
+
return Array.isArray(value) ? value.indexOf(overrideValue) === -1 : value !== overrideValue
|
|
363
|
+
case 'is_set':
|
|
364
|
+
return key in propertyValues
|
|
365
|
+
case 'icontains':
|
|
366
|
+
return String(overrideValue).toLowerCase().includes(String(value).toLowerCase())
|
|
367
|
+
case 'not_icontains':
|
|
368
|
+
return !String(overrideValue).toLowerCase().includes(String(value).toLowerCase())
|
|
369
|
+
case 'regex':
|
|
370
|
+
return isValidRegex(String(value)) && String(overrideValue).match(String(value)) !== null
|
|
371
|
+
case 'not_regex':
|
|
372
|
+
return isValidRegex(String(value)) && String(overrideValue).match(String(value)) === null
|
|
373
|
+
case 'gt':
|
|
374
|
+
return typeof overrideValue == typeof value && overrideValue > value
|
|
375
|
+
case 'gte':
|
|
376
|
+
return typeof overrideValue == typeof value && overrideValue >= value
|
|
377
|
+
case 'lt':
|
|
378
|
+
return typeof overrideValue == typeof value && overrideValue < value
|
|
379
|
+
case 'lte':
|
|
380
|
+
return typeof overrideValue == typeof value && overrideValue <= value
|
|
381
|
+
default:
|
|
382
|
+
console.error(`Unknown operator: ${operator}`)
|
|
383
|
+
return false
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function isValidRegex(regex: string): boolean {
|
|
388
|
+
try {
|
|
389
|
+
new RegExp(regex)
|
|
390
|
+
return true
|
|
391
|
+
} catch (err) {
|
|
392
|
+
return false
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export { FeatureFlagsPoller, matchProperty, InconclusiveMatchError, ClientError }
|