@atproto/bsky 0.0.216 → 0.0.217
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 +7 -0
- package/dist/api/app/bsky/feed/searchPosts.d.ts.map +1 -1
- package/dist/api/app/bsky/feed/searchPosts.js +6 -4
- package/dist/api/app/bsky/feed/searchPosts.js.map +1 -1
- package/dist/api/app/bsky/graph/getSuggestedFollowsByActor.js.map +1 -1
- package/dist/api/app/bsky/unspecced/getPostThreadV2.js +1 -1
- package/dist/api/app/bsky/unspecced/getPostThreadV2.js.map +1 -1
- package/dist/api/app/bsky/unspecced/getSuggestedOnboardingUsers.d.ts.map +1 -1
- package/dist/api/app/bsky/unspecced/getSuggestedOnboardingUsers.js +9 -2
- package/dist/api/app/bsky/unspecced/getSuggestedOnboardingUsers.js.map +1 -1
- package/dist/api/app/bsky/unspecced/getSuggestedUsers.d.ts.map +1 -1
- package/dist/api/app/bsky/unspecced/getSuggestedUsers.js +9 -2
- package/dist/api/app/bsky/unspecced/getSuggestedUsers.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +3 -3
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +2 -2
- package/dist/context.js.map +1 -1
- package/dist/feature-gates/gates.d.ts +5 -0
- package/dist/feature-gates/gates.d.ts.map +1 -0
- package/dist/feature-gates/gates.js +6 -0
- package/dist/feature-gates/gates.js.map +1 -0
- package/dist/feature-gates/index.d.ts +24 -0
- package/dist/feature-gates/index.d.ts.map +1 -0
- package/dist/feature-gates/index.js +135 -0
- package/dist/feature-gates/index.js.map +1 -0
- package/dist/feature-gates/metrics.d.ts +32 -0
- package/dist/feature-gates/metrics.d.ts.map +1 -0
- package/dist/feature-gates/metrics.js +100 -0
- package/dist/feature-gates/metrics.js.map +1 -0
- package/dist/feature-gates/metrics.test.d.ts +2 -0
- package/dist/feature-gates/metrics.test.d.ts.map +1 -0
- package/dist/feature-gates/metrics.test.js +152 -0
- package/dist/feature-gates/metrics.test.js.map +1 -0
- package/dist/feature-gates/types.d.ts +49 -0
- package/dist/feature-gates/types.d.ts.map +1 -0
- package/dist/feature-gates/types.js +3 -0
- package/dist/feature-gates/types.js.map +1 -0
- package/dist/feature-gates/utils.d.ts +21 -0
- package/dist/feature-gates/utils.d.ts.map +1 -0
- package/dist/feature-gates/utils.js +85 -0
- package/dist/feature-gates/utils.js.map +1 -0
- package/dist/hydration/hydrator.d.ts +8 -3
- package/dist/hydration/hydrator.d.ts.map +1 -1
- package/dist/hydration/hydrator.js +9 -5
- package/dist/hydration/hydrator.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -6
- package/dist/index.js.map +1 -1
- package/dist/views/index.d.ts.map +1 -1
- package/dist/views/index.js +3 -4
- package/dist/views/index.js.map +1 -1
- package/package.json +10 -10
- package/src/api/app/bsky/feed/searchPosts.ts +10 -8
- package/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +0 -1
- package/src/api/app/bsky/unspecced/getPostThreadV2.ts +3 -3
- package/src/api/app/bsky/unspecced/getSuggestedOnboardingUsers.ts +13 -6
- package/src/api/app/bsky/unspecced/getSuggestedUsers.ts +13 -6
- package/src/config.ts +8 -0
- package/src/context.ts +4 -4
- package/src/feature-gates/README.md +47 -0
- package/src/feature-gates/gates.ts +9 -0
- package/src/feature-gates/index.ts +146 -0
- package/src/feature-gates/metrics.test.ts +196 -0
- package/src/feature-gates/metrics.ts +107 -0
- package/src/feature-gates/types.ts +52 -0
- package/src/feature-gates/utils.ts +90 -0
- package/src/hydration/hydrator.ts +12 -6
- package/src/index.ts +8 -7
- package/src/views/index.ts +5 -8
- package/tests/views/thread.test.ts +2 -0
- package/tsconfig.build.tsbuildinfo +1 -1
- package/dist/feature-gates.d.ts +0 -44
- package/dist/feature-gates.d.ts.map +0 -1
- package/dist/feature-gates.js +0 -133
- package/dist/feature-gates.js.map +0 -1
- package/src/feature-gates.ts +0 -136
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atproto/bsky",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.217",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Reference implementation of app.bsky App View (Bluesky API)",
|
|
6
6
|
"keywords": [
|
|
@@ -51,18 +51,18 @@
|
|
|
51
51
|
"uint8arrays": "3.0.0",
|
|
52
52
|
"undici": "^6.19.8",
|
|
53
53
|
"zod": "3.23.8",
|
|
54
|
-
"@atproto-labs/fetch-node": "^0.2.0",
|
|
55
54
|
"@atproto-labs/xrpc-utils": "^0.0.24",
|
|
56
|
-
"@atproto/
|
|
57
|
-
"@atproto/
|
|
55
|
+
"@atproto-labs/fetch-node": "^0.2.0",
|
|
56
|
+
"@atproto/api": "^0.19.0",
|
|
58
57
|
"@atproto/crypto": "^0.4.5",
|
|
59
|
-
"@atproto/identity": "^0.4.11",
|
|
60
|
-
"@atproto/lexicon": "^0.6.1",
|
|
61
|
-
"@atproto/repo": "^0.8.12",
|
|
62
58
|
"@atproto/did": "^0.3.0",
|
|
59
|
+
"@atproto/identity": "^0.4.12",
|
|
60
|
+
"@atproto/common": "^0.5.13",
|
|
61
|
+
"@atproto/repo": "^0.8.12",
|
|
63
62
|
"@atproto/sync": "^0.1.39",
|
|
64
63
|
"@atproto/syntax": "^0.4.3",
|
|
65
|
-
"@atproto/
|
|
64
|
+
"@atproto/lexicon": "^0.6.1",
|
|
65
|
+
"@atproto/xrpc-server": "^0.10.14"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@bufbuild/buf": "^1.28.1",
|
|
@@ -78,9 +78,9 @@
|
|
|
78
78
|
"jest": "^28.1.2",
|
|
79
79
|
"ts-node": "^10.8.2",
|
|
80
80
|
"typescript": "^5.6.3",
|
|
81
|
-
"@atproto/api": "^0.
|
|
81
|
+
"@atproto/api": "^0.19.0",
|
|
82
82
|
"@atproto/lex-cli": "^0.9.8",
|
|
83
|
-
"@atproto/pds": "^0.4.
|
|
83
|
+
"@atproto/pds": "^0.4.212",
|
|
84
84
|
"@atproto/xrpc": "^0.7.7"
|
|
85
85
|
},
|
|
86
86
|
"scripts": {
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
PostSearchQuery,
|
|
8
8
|
parsePostSearchQuery,
|
|
9
9
|
} from '../../../../data-plane/server/util'
|
|
10
|
-
import { FeatureGateID } from '../../../../feature-gates'
|
|
11
10
|
import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator'
|
|
12
11
|
import { parseString } from '../../../../hydration/util'
|
|
13
12
|
import { Server } from '../../../../lexicon'
|
|
@@ -39,9 +38,12 @@ export default function (server: Server, ctx: AppContext) {
|
|
|
39
38
|
const hydrateCtx = await ctx.hydrator.createContext({
|
|
40
39
|
labelers,
|
|
41
40
|
viewer,
|
|
42
|
-
|
|
43
|
-
[
|
|
44
|
-
|
|
41
|
+
featureGatesMap: ctx.featureGatesClient.checkGates(
|
|
42
|
+
['search:filtering_exploration:enable'],
|
|
43
|
+
{
|
|
44
|
+
viewer,
|
|
45
|
+
req,
|
|
46
|
+
},
|
|
45
47
|
),
|
|
46
48
|
})
|
|
47
49
|
const results = await searchPosts(
|
|
@@ -109,8 +111,8 @@ const hydration = async (
|
|
|
109
111
|
params.hydrateCtx,
|
|
110
112
|
undefined,
|
|
111
113
|
{
|
|
112
|
-
processDynamicTagsForView: params.hydrateCtx.
|
|
113
|
-
|
|
114
|
+
processDynamicTagsForView: params.hydrateCtx.featureGatesMap.get(
|
|
115
|
+
'search:filtering_exploration:enable',
|
|
114
116
|
)
|
|
115
117
|
? 'search'
|
|
116
118
|
: undefined,
|
|
@@ -139,8 +141,8 @@ const noBlocksOrTagged = (inputs: RulesFnInput<Context, Params, Skeleton>) => {
|
|
|
139
141
|
|
|
140
142
|
let tagged = false
|
|
141
143
|
if (
|
|
142
|
-
params.hydrateCtx.
|
|
143
|
-
|
|
144
|
+
params.hydrateCtx.featureGatesMap.get(
|
|
145
|
+
'search:filtering_exploration:enable',
|
|
144
146
|
)
|
|
145
147
|
) {
|
|
146
148
|
tagged = post.tags.has(ctx.cfg.visibilityTagHide)
|
|
@@ -33,9 +33,9 @@ export default function (server: Server, ctx: AppContext) {
|
|
|
33
33
|
viewer,
|
|
34
34
|
includeTakedowns,
|
|
35
35
|
include3pBlocks,
|
|
36
|
-
|
|
37
|
-
[
|
|
38
|
-
|
|
36
|
+
featureGatesMap: ctx.featureGatesClient.checkGates(
|
|
37
|
+
['threads:reply_ranking_exploration:enable'],
|
|
38
|
+
{ viewer, req },
|
|
39
39
|
),
|
|
40
40
|
})
|
|
41
41
|
|
|
@@ -2,7 +2,6 @@ import AtpAgent from '@atproto/api'
|
|
|
2
2
|
import { dedupeStrs, mapDefined, noUndefinedVals } from '@atproto/common'
|
|
3
3
|
import { InternalServerError } from '@atproto/xrpc-server'
|
|
4
4
|
import { AppContext } from '../../../../context'
|
|
5
|
-
import { FeatureGates } from '../../../../feature-gates'
|
|
6
5
|
import {
|
|
7
6
|
HydrateCtx,
|
|
8
7
|
Hydrator,
|
|
@@ -31,7 +30,17 @@ export default function (server: Server, ctx: AppContext) {
|
|
|
31
30
|
handler: async ({ auth, params, req }) => {
|
|
32
31
|
const viewer = auth.credentials.iss
|
|
33
32
|
const labelers = ctx.reqLabelers(req)
|
|
34
|
-
const hydrateCtx = await ctx.hydrator.createContext({
|
|
33
|
+
const hydrateCtx = await ctx.hydrator.createContext({
|
|
34
|
+
labelers,
|
|
35
|
+
viewer,
|
|
36
|
+
featureGatesMap: ctx.featureGatesClient.checkGates(
|
|
37
|
+
['suggested_onboarding_users:discover_agent:enable'],
|
|
38
|
+
{
|
|
39
|
+
viewer,
|
|
40
|
+
req,
|
|
41
|
+
},
|
|
42
|
+
),
|
|
43
|
+
})
|
|
35
44
|
const headers = noUndefinedVals({
|
|
36
45
|
'accept-language': req.headers['accept-language'],
|
|
37
46
|
'x-bsky-topics': Array.isArray(req.headers['x-bsky-topics'])
|
|
@@ -100,9 +109,8 @@ const skeletonFromTopics = async (input: SkeletonFnInput<Context, Params>) => {
|
|
|
100
109
|
}
|
|
101
110
|
|
|
102
111
|
const skeleton = async (input: SkeletonFnInput<Context, Params>) => {
|
|
103
|
-
const useDiscover = input.
|
|
104
|
-
|
|
105
|
-
input.ctx.featureGates.userContext({ did: input.params.hydrateCtx.viewer }),
|
|
112
|
+
const useDiscover = input.params.hydrateCtx.featureGatesMap.get(
|
|
113
|
+
'suggested_onboarding_users:discover_agent:enable',
|
|
106
114
|
)
|
|
107
115
|
const skeletonFn = useDiscover ? skeletonFromDiscover : skeletonFromTopics
|
|
108
116
|
return skeletonFn(input)
|
|
@@ -161,7 +169,6 @@ type Context = {
|
|
|
161
169
|
views: Views
|
|
162
170
|
topicsAgent: AtpAgent | undefined
|
|
163
171
|
suggestionsAgent: AtpAgent | undefined
|
|
164
|
-
featureGates: FeatureGates
|
|
165
172
|
}
|
|
166
173
|
|
|
167
174
|
type Params = QueryParams & {
|
|
@@ -2,7 +2,6 @@ import AtpAgent from '@atproto/api'
|
|
|
2
2
|
import { dedupeStrs, mapDefined, noUndefinedVals } from '@atproto/common'
|
|
3
3
|
import { InternalServerError } from '@atproto/xrpc-server'
|
|
4
4
|
import { AppContext } from '../../../../context'
|
|
5
|
-
import { FeatureGates } from '../../../../feature-gates'
|
|
6
5
|
import {
|
|
7
6
|
HydrateCtx,
|
|
8
7
|
Hydrator,
|
|
@@ -31,7 +30,17 @@ export default function (server: Server, ctx: AppContext) {
|
|
|
31
30
|
handler: async ({ auth, params, req }) => {
|
|
32
31
|
const viewer = auth.credentials.iss
|
|
33
32
|
const labelers = ctx.reqLabelers(req)
|
|
34
|
-
const hydrateCtx = await ctx.hydrator.createContext({
|
|
33
|
+
const hydrateCtx = await ctx.hydrator.createContext({
|
|
34
|
+
labelers,
|
|
35
|
+
viewer,
|
|
36
|
+
featureGatesMap: ctx.featureGatesClient.checkGates(
|
|
37
|
+
['suggested_users:discover_agent:enable'],
|
|
38
|
+
{
|
|
39
|
+
viewer,
|
|
40
|
+
req,
|
|
41
|
+
},
|
|
42
|
+
),
|
|
43
|
+
})
|
|
35
44
|
const headers = noUndefinedVals({
|
|
36
45
|
'accept-language': req.headers['accept-language'],
|
|
37
46
|
'x-bsky-topics': Array.isArray(req.headers['x-bsky-topics'])
|
|
@@ -98,9 +107,8 @@ const skeletonFromTopics = async (input: SkeletonFnInput<Context, Params>) => {
|
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
const skeleton = async (input: SkeletonFnInput<Context, Params>) => {
|
|
101
|
-
const useDiscover = input.
|
|
102
|
-
|
|
103
|
-
input.ctx.featureGates.userContext({ did: input.params.hydrateCtx.viewer }),
|
|
110
|
+
const useDiscover = input.params.hydrateCtx.featureGatesMap.get(
|
|
111
|
+
'suggested_users:discover_agent:enable',
|
|
104
112
|
)
|
|
105
113
|
const skeletonFn = useDiscover ? skeletonFromDiscover : skeletonFromTopics
|
|
106
114
|
return skeletonFn(input)
|
|
@@ -159,7 +167,6 @@ type Context = {
|
|
|
159
167
|
views: Views
|
|
160
168
|
topicsAgent: AtpAgent | undefined
|
|
161
169
|
suggestionsAgent: AtpAgent | undefined
|
|
162
|
-
featureGates: FeatureGates
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
type Params = QueryParams & {
|
package/src/config.ts
CHANGED
|
@@ -81,6 +81,7 @@ export interface ServerConfigValues {
|
|
|
81
81
|
indexedAtEpoch?: Date
|
|
82
82
|
// misc/dev
|
|
83
83
|
blobCacheLocation?: string
|
|
84
|
+
eventProxyTrackingEndpoint?: string
|
|
84
85
|
growthBookApiHost?: string
|
|
85
86
|
growthBookClientKey?: string
|
|
86
87
|
// threads
|
|
@@ -213,6 +214,8 @@ export class ServerConfig {
|
|
|
213
214
|
const modServiceDid = process.env.MOD_SERVICE_DID
|
|
214
215
|
assert(modServiceDid)
|
|
215
216
|
|
|
217
|
+
const eventProxyTrackingEndpoint =
|
|
218
|
+
process.env.BSKY_EVENT_PROXY_TRACKING_ENDPOINT || undefined
|
|
216
219
|
const growthBookApiHost = process.env.BSKY_GROWTHBOOK_API_HOST || undefined
|
|
217
220
|
const growthBookClientKey =
|
|
218
221
|
process.env.NODE_ENV === 'test'
|
|
@@ -369,6 +372,7 @@ export class ServerConfig {
|
|
|
369
372
|
blobRateLimitBypassHostname,
|
|
370
373
|
adminPasswords,
|
|
371
374
|
modServiceDid,
|
|
375
|
+
eventProxyTrackingEndpoint,
|
|
372
376
|
growthBookApiHost,
|
|
373
377
|
growthBookClientKey,
|
|
374
378
|
clientCheckEmailConfirmed,
|
|
@@ -570,6 +574,10 @@ export class ServerConfig {
|
|
|
570
574
|
return this.cfg.blobCacheLocation
|
|
571
575
|
}
|
|
572
576
|
|
|
577
|
+
get eventProxyTrackingEndpoint() {
|
|
578
|
+
return this.cfg.eventProxyTrackingEndpoint
|
|
579
|
+
}
|
|
580
|
+
|
|
573
581
|
get growthBookApiHost() {
|
|
574
582
|
return this.cfg.growthBookApiHost
|
|
575
583
|
}
|
package/src/context.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { BsyncClient } from './bsync'
|
|
|
10
10
|
import { ServerConfig } from './config'
|
|
11
11
|
import { CourierClient } from './courier'
|
|
12
12
|
import { DataPlaneClient, HostList } from './data-plane/client'
|
|
13
|
-
import {
|
|
13
|
+
import { FeatureGatesClient } from './feature-gates'
|
|
14
14
|
import { Hydrator } from './hydration/hydrator'
|
|
15
15
|
import { KwsClient } from './kws'
|
|
16
16
|
import { httpLogger as log } from './logger'
|
|
@@ -42,7 +42,7 @@ export class AppContext {
|
|
|
42
42
|
courierClient: CourierClient | undefined
|
|
43
43
|
rolodexClient: RolodexClient | undefined
|
|
44
44
|
authVerifier: AuthVerifier
|
|
45
|
-
|
|
45
|
+
featureGatesClient: FeatureGatesClient
|
|
46
46
|
blobDispatcher: Dispatcher
|
|
47
47
|
kwsClient: KwsClient | undefined
|
|
48
48
|
},
|
|
@@ -116,8 +116,8 @@ export class AppContext {
|
|
|
116
116
|
return this.opts.authVerifier
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
get
|
|
120
|
-
return this.opts.
|
|
119
|
+
get featureGatesClient(): FeatureGatesClient {
|
|
120
|
+
return this.opts.featureGatesClient
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
get blobDispatcher(): Dispatcher {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Feature Gates
|
|
2
|
+
|
|
3
|
+
A thin wrapper around [GrowthBook](https://www.growthbook.io/) for feature flag
|
|
4
|
+
evaluation and experiment tracking.
|
|
5
|
+
|
|
6
|
+
## Usage
|
|
7
|
+
|
|
8
|
+
Call `checkGates` at the **top of each request handler** to evaluate feature
|
|
9
|
+
gates for the current user. This ensures consistent targeting throughout the
|
|
10
|
+
request lifecycle.
|
|
11
|
+
|
|
12
|
+
> [!NOTE]
|
|
13
|
+
> Only pass in the gates you wish to check for this endpoint. Passing in more
|
|
14
|
+
> will result in extraneous calls to our exposures endpoint, which could skew
|
|
15
|
+
> experiment results in unexpected ways.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
const hydrateCtx = await ctx.hydrator.createContext({
|
|
19
|
+
// ...
|
|
20
|
+
featureGatesMap: ctx.featureGatesClient.checkGates(
|
|
21
|
+
['threads:reply_ranking_exploration:enable'],
|
|
22
|
+
{ viewer, req },
|
|
23
|
+
),
|
|
24
|
+
})
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The returned `CheckedFeatureGatesMap` can then be passed through context and
|
|
28
|
+
accessed wherever needed.
|
|
29
|
+
|
|
30
|
+
## User Identification
|
|
31
|
+
|
|
32
|
+
If the user is authenticated, we use their DID as the identifier for feature
|
|
33
|
+
targeting via the `viewer` param of the `checkGates` call.
|
|
34
|
+
|
|
35
|
+
For unauthenticated users, and for experiments that don't require DID-level
|
|
36
|
+
targeting, we rely on identifiers passed from the client as headers:
|
|
37
|
+
|
|
38
|
+
- `X-Bsky-Stable-Id` - persistent device/client identifier
|
|
39
|
+
- `X-Bsky-Session-Id` - current session identifier
|
|
40
|
+
|
|
41
|
+
> [!WARNING]
|
|
42
|
+
> If both `stableId` and `did` are missing, all gates return `false`. This
|
|
43
|
+
> prevents untargeted users from being enrolled in experiments.
|
|
44
|
+
|
|
45
|
+
## Adding New Gates
|
|
46
|
+
|
|
47
|
+
Add new gate IDs to the `FeatureGate` type in `gates.ts`.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { GrowthBookClient } from '@growthbook/growthbook'
|
|
2
|
+
import { featureGatesLogger } from '../logger'
|
|
3
|
+
import { FeatureGate } from './gates'
|
|
4
|
+
import { MetricsClient } from './metrics'
|
|
5
|
+
import { CheckedFeatureGatesMap, RawUserContext } from './types'
|
|
6
|
+
import {
|
|
7
|
+
extractParsedUserContextFromGrowthBookUserContext,
|
|
8
|
+
parseRawUserContext,
|
|
9
|
+
parsedUserContextToTrackingMetadata,
|
|
10
|
+
} from './utils'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* We want this to be sufficiently high that we don't time out under
|
|
14
|
+
* normal conditions, but not so high that it takes too long to boot
|
|
15
|
+
* the server.
|
|
16
|
+
*/
|
|
17
|
+
const FETCH_TIMEOUT = 3e3 // 3 seconds
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* StatSig used to default to every 10s, but I think 1m is fine
|
|
21
|
+
*/
|
|
22
|
+
const REFETCH_INTERVAL = 60e3 // 1 minute
|
|
23
|
+
|
|
24
|
+
export class FeatureGatesClient {
|
|
25
|
+
ready = false
|
|
26
|
+
client: GrowthBookClient | undefined = undefined
|
|
27
|
+
refreshInterval: NodeJS.Timeout | undefined = undefined
|
|
28
|
+
metrics: MetricsClient
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
private config: {
|
|
32
|
+
growthBookApiHost?: string
|
|
33
|
+
growthBookClientKey?: string
|
|
34
|
+
eventProxyTrackingEndpoint?: string
|
|
35
|
+
},
|
|
36
|
+
) {
|
|
37
|
+
this.metrics = new MetricsClient({
|
|
38
|
+
trackingEndpoint: config.eventProxyTrackingEndpoint,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async start() {
|
|
43
|
+
if (!this.config.growthBookApiHost || !this.config.growthBookClientKey) {
|
|
44
|
+
featureGatesLogger.info(
|
|
45
|
+
{},
|
|
46
|
+
'feature gates not configured, skipping initialization',
|
|
47
|
+
)
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
this.client = new GrowthBookClient({
|
|
53
|
+
apiHost: this.config.growthBookApiHost,
|
|
54
|
+
clientKey: this.config.growthBookClientKey,
|
|
55
|
+
onFeatureUsage: (feature, result, userContext) => {
|
|
56
|
+
this.metrics.track(
|
|
57
|
+
'feature:viewed',
|
|
58
|
+
{
|
|
59
|
+
featureId: feature,
|
|
60
|
+
featureResultValue: result.value,
|
|
61
|
+
experimentId: result.experiment?.key,
|
|
62
|
+
variationId: result.experimentResult?.key,
|
|
63
|
+
},
|
|
64
|
+
parsedUserContextToTrackingMetadata(
|
|
65
|
+
extractParsedUserContextFromGrowthBookUserContext(userContext),
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
},
|
|
69
|
+
trackingCallback: (experiment, result, userContext) => {
|
|
70
|
+
this.metrics.track(
|
|
71
|
+
'experiment:viewed',
|
|
72
|
+
{
|
|
73
|
+
experimentId: experiment.key,
|
|
74
|
+
variationId: result.key,
|
|
75
|
+
},
|
|
76
|
+
parsedUserContextToTrackingMetadata(
|
|
77
|
+
extractParsedUserContextFromGrowthBookUserContext(userContext),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const { source, error } = await this.client.init({
|
|
84
|
+
timeout: FETCH_TIMEOUT,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* This does not necessarily mean that the client completely failed,
|
|
89
|
+
* since it could just be that the request timed out. It may succeed
|
|
90
|
+
* after the timeout, or later during refreshes.
|
|
91
|
+
*
|
|
92
|
+
* @see https://docs.growthbook.io/lib/node#error-handling
|
|
93
|
+
*/
|
|
94
|
+
if (error) {
|
|
95
|
+
featureGatesLogger.error(
|
|
96
|
+
{ err: error, source },
|
|
97
|
+
'Client failed to initialize normally',
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Set up periodic refresh of feature definitions
|
|
103
|
+
*
|
|
104
|
+
* @see https://docs.growthbook.io/lib/node#refreshing-features
|
|
105
|
+
*/
|
|
106
|
+
this.refreshInterval = setInterval(async () => {
|
|
107
|
+
try {
|
|
108
|
+
await this.client?.refreshFeatures({
|
|
109
|
+
timeout: FETCH_TIMEOUT,
|
|
110
|
+
})
|
|
111
|
+
} catch (err) {
|
|
112
|
+
featureGatesLogger.error({ err }, 'Failed to refresh features')
|
|
113
|
+
}
|
|
114
|
+
}, REFETCH_INTERVAL)
|
|
115
|
+
|
|
116
|
+
/* Ready or not, here we come */
|
|
117
|
+
this.ready = true
|
|
118
|
+
} catch (err) {
|
|
119
|
+
featureGatesLogger.error({ err }, 'Client initialization failed')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
destroy() {
|
|
124
|
+
if (this.ready) {
|
|
125
|
+
this.ready = false
|
|
126
|
+
if (this.refreshInterval) {
|
|
127
|
+
clearInterval(this.refreshInterval)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
this.metrics.stop()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Evaluate multiple feature gates for a given user, returning a map of gate
|
|
135
|
+
* ID to boolean result.
|
|
136
|
+
*/
|
|
137
|
+
checkGates(
|
|
138
|
+
gates: FeatureGate[],
|
|
139
|
+
rawUserContext: RawUserContext,
|
|
140
|
+
): CheckedFeatureGatesMap {
|
|
141
|
+
const gb = this.client
|
|
142
|
+
const attributes = parseRawUserContext(rawUserContext)
|
|
143
|
+
if (!gb || !this.ready) return new Map(gates.map((g) => [g, false]))
|
|
144
|
+
return new Map(gates.map((g) => [g, gb.isOn(g, { attributes })]))
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/// <reference types="jest" />
|
|
2
|
+
import { featureGatesLogger } from '../logger'
|
|
3
|
+
import { MetricsClient } from './metrics'
|
|
4
|
+
|
|
5
|
+
jest.mock('../logger', () => ({
|
|
6
|
+
featureGatesLogger: {
|
|
7
|
+
error: jest.fn(),
|
|
8
|
+
},
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
type TestEvents = {
|
|
12
|
+
click: { button: string }
|
|
13
|
+
view: { screen: string }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Helper to flush promises and timers
|
|
17
|
+
const flushPromises = () => new Promise((r) => setImmediate(r))
|
|
18
|
+
|
|
19
|
+
describe('MetricsClient', () => {
|
|
20
|
+
let fetchMock: jest.Mock
|
|
21
|
+
let fetchRequests: { body: any }[]
|
|
22
|
+
let client: MetricsClient<TestEvents>
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
jest.useFakeTimers({ doNotFake: ['setImmediate', 'performance'] })
|
|
26
|
+
fetchRequests = []
|
|
27
|
+
fetchMock = jest.fn().mockImplementation(async (_url, options) => {
|
|
28
|
+
const body = JSON.parse(options.body)
|
|
29
|
+
fetchRequests.push({ body })
|
|
30
|
+
return { ok: true, status: 200, text: async () => '' }
|
|
31
|
+
})
|
|
32
|
+
global.fetch = fetchMock
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
client?.stop()
|
|
37
|
+
jest.useRealTimers()
|
|
38
|
+
jest.clearAllMocks()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('flushes events on interval', async () => {
|
|
42
|
+
client = new MetricsClient<TestEvents>({
|
|
43
|
+
trackingEndpoint: 'https://test.metrics.api',
|
|
44
|
+
})
|
|
45
|
+
client.track('click', { button: 'submit' })
|
|
46
|
+
client.track('view', { screen: 'home' })
|
|
47
|
+
|
|
48
|
+
expect(fetchRequests).toHaveLength(0)
|
|
49
|
+
|
|
50
|
+
// Advance past the 10 second interval
|
|
51
|
+
jest.advanceTimersByTime(10_000)
|
|
52
|
+
await flushPromises()
|
|
53
|
+
|
|
54
|
+
expect(fetchRequests).toHaveLength(1)
|
|
55
|
+
expect(fetchRequests[0].body.events).toHaveLength(2)
|
|
56
|
+
expect(fetchRequests[0].body.events[0].event).toBe('click')
|
|
57
|
+
expect(fetchRequests[0].body.events[1].event).toBe('view')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('flushes when maxBatchSize is exceeded', async () => {
|
|
61
|
+
client = new MetricsClient<TestEvents>({
|
|
62
|
+
trackingEndpoint: 'https://test.metrics.api',
|
|
63
|
+
})
|
|
64
|
+
client.maxBatchSize = 5
|
|
65
|
+
|
|
66
|
+
// Add events up to maxBatchSize (should not flush yet)
|
|
67
|
+
for (let i = 0; i < 5; i++) {
|
|
68
|
+
client.track('click', { button: `btn-${i}` })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
expect(fetchRequests).toHaveLength(0)
|
|
72
|
+
|
|
73
|
+
// One more event should trigger flush (> maxBatchSize)
|
|
74
|
+
client.track('click', { button: 'btn-trigger' })
|
|
75
|
+
await flushPromises()
|
|
76
|
+
|
|
77
|
+
expect(fetchRequests).toHaveLength(1)
|
|
78
|
+
expect(fetchRequests[0].body.events).toHaveLength(6)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('logs error on failed request', async () => {
|
|
82
|
+
fetchMock.mockImplementation(async () => {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
status: 500,
|
|
86
|
+
text: async () => 'Internal Server Error',
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
client = new MetricsClient<TestEvents>({
|
|
91
|
+
trackingEndpoint: 'https://test.metrics.api',
|
|
92
|
+
})
|
|
93
|
+
client.track('click', { button: 'submit' })
|
|
94
|
+
|
|
95
|
+
// Trigger flush via interval
|
|
96
|
+
jest.advanceTimersByTime(10_000)
|
|
97
|
+
await flushPromises()
|
|
98
|
+
|
|
99
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
100
|
+
expect(featureGatesLogger.error).toHaveBeenCalledWith(
|
|
101
|
+
expect.objectContaining({
|
|
102
|
+
err: expect.any(Error),
|
|
103
|
+
}),
|
|
104
|
+
'Failed to send metrics',
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('handles fetch text() error gracefully', async () => {
|
|
109
|
+
fetchMock.mockImplementation(async () => {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
status: 500,
|
|
113
|
+
text: async () => {
|
|
114
|
+
throw new Error('Failed to read response')
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
client = new MetricsClient<TestEvents>({
|
|
120
|
+
trackingEndpoint: 'https://test.metrics.api',
|
|
121
|
+
})
|
|
122
|
+
client.track('click', { button: 'submit' })
|
|
123
|
+
|
|
124
|
+
// Trigger flush - should not throw
|
|
125
|
+
jest.advanceTimersByTime(10_000)
|
|
126
|
+
await flushPromises()
|
|
127
|
+
|
|
128
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
129
|
+
expect(featureGatesLogger.error).toHaveBeenCalledWith(
|
|
130
|
+
expect.objectContaining({
|
|
131
|
+
err: expect.objectContaining({
|
|
132
|
+
message: expect.stringContaining('Unknown error'),
|
|
133
|
+
}),
|
|
134
|
+
}),
|
|
135
|
+
'Failed to send metrics',
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('flushes when stop() is called', async () => {
|
|
140
|
+
client = new MetricsClient<TestEvents>({
|
|
141
|
+
trackingEndpoint: 'https://test.metrics.api',
|
|
142
|
+
})
|
|
143
|
+
client.track('click', { button: 'submit' })
|
|
144
|
+
|
|
145
|
+
expect(fetchRequests).toHaveLength(0)
|
|
146
|
+
|
|
147
|
+
// Stop should flush remaining events
|
|
148
|
+
client.stop()
|
|
149
|
+
await flushPromises()
|
|
150
|
+
|
|
151
|
+
expect(fetchRequests).toHaveLength(1)
|
|
152
|
+
expect(fetchRequests[0].body.events).toHaveLength(1)
|
|
153
|
+
expect(fetchRequests[0].body.events[0].event).toBe('click')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('does not send if trackingEndpoint is not configured', async () => {
|
|
157
|
+
client = new MetricsClient<TestEvents>({})
|
|
158
|
+
client.track('click', { button: 'submit' })
|
|
159
|
+
|
|
160
|
+
// Trigger flush via interval
|
|
161
|
+
jest.advanceTimersByTime(10_000)
|
|
162
|
+
await flushPromises()
|
|
163
|
+
|
|
164
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('start() is idempotent', async () => {
|
|
168
|
+
client = new MetricsClient<TestEvents>({
|
|
169
|
+
trackingEndpoint: 'https://test.metrics.api',
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// track() calls start() internally
|
|
173
|
+
client.track('click', { button: 'submit' })
|
|
174
|
+
client.start()
|
|
175
|
+
client.start()
|
|
176
|
+
|
|
177
|
+
// Advance past interval - should only flush once
|
|
178
|
+
jest.advanceTimersByTime(10_000)
|
|
179
|
+
await flushPromises()
|
|
180
|
+
|
|
181
|
+
expect(fetchRequests).toHaveLength(1)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('does not flush if queue is empty', async () => {
|
|
185
|
+
client = new MetricsClient<TestEvents>({
|
|
186
|
+
trackingEndpoint: 'https://test.metrics.api',
|
|
187
|
+
})
|
|
188
|
+
client.start()
|
|
189
|
+
|
|
190
|
+
// Advance past interval with empty queue
|
|
191
|
+
jest.advanceTimersByTime(10_000)
|
|
192
|
+
await flushPromises()
|
|
193
|
+
|
|
194
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
195
|
+
})
|
|
196
|
+
})
|