@atproto/bsky 0.0.215 → 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 +13 -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 +2 -0
- 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 +10 -3
- 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/lexicon/index.d.ts +2 -2
- package/dist/lexicon/index.d.ts.map +1 -1
- package/dist/lexicon/index.js +4 -4
- package/dist/lexicon/index.js.map +1 -1
- package/dist/lexicon/lexicons.d.ts +116 -100
- package/dist/lexicon/lexicons.d.ts.map +1 -1
- package/dist/lexicon/lexicons.js +59 -51
- package/dist/lexicon/lexicons.js.map +1 -1
- package/dist/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.d.ts +3 -1
- package/dist/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.d.ts.map +1 -1
- package/dist/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.js.map +1 -1
- package/dist/lexicon/types/app/bsky/unspecced/{getSuggestedOnboardingUsersSkeleton.d.ts → getOnboardingSuggestedUsersSkeleton.d.ts} +1 -1
- package/dist/lexicon/types/app/bsky/unspecced/{getSuggestedOnboardingUsersSkeleton.d.ts.map → getOnboardingSuggestedUsersSkeleton.d.ts.map} +1 -1
- package/dist/lexicon/types/app/bsky/unspecced/{getSuggestedOnboardingUsersSkeleton.js → getOnboardingSuggestedUsersSkeleton.js} +2 -2
- package/dist/lexicon/types/app/bsky/unspecced/{getSuggestedOnboardingUsersSkeleton.js.map → getOnboardingSuggestedUsersSkeleton.js.map} +1 -1
- package/dist/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.d.ts +3 -1
- package/dist/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.d.ts.map +1 -1
- package/dist/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.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 +9 -9
- package/src/api/app/bsky/feed/searchPosts.ts +10 -8
- package/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +3 -1
- package/src/api/app/bsky/unspecced/getPostThreadV2.ts +3 -3
- package/src/api/app/bsky/unspecced/getSuggestedOnboardingUsers.ts +14 -7
- 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/lexicon/index.ts +13 -13
- package/src/lexicon/lexicons.ts +63 -55
- package/src/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.ts +3 -1
- package/src/lexicon/types/app/bsky/unspecced/{getSuggestedOnboardingUsersSkeleton.ts → getOnboardingSuggestedUsersSkeleton.ts} +1 -1
- package/src/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.ts +3 -1
- package/src/views/index.ts +5 -8
- package/tests/views/get-suggested-onboarding-users.test.ts +1 -1
- 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
58
|
"@atproto/did": "^0.3.0",
|
|
60
|
-
"@atproto/identity": "^0.4.
|
|
61
|
-
"@atproto/
|
|
59
|
+
"@atproto/identity": "^0.4.12",
|
|
60
|
+
"@atproto/common": "^0.5.13",
|
|
62
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)
|
|
@@ -75,6 +75,7 @@ const skeleton = async (input: SkeletonFnInput<Context, Params>) => {
|
|
|
75
75
|
isFallback: !res.data.relativeToDid,
|
|
76
76
|
suggestedDids: res.data.actors.map((a) => a.did),
|
|
77
77
|
recId: res.data.recId,
|
|
78
|
+
recIdStr: res.data.recIdStr,
|
|
78
79
|
headers: res.headers,
|
|
79
80
|
}
|
|
80
81
|
} else {
|
|
@@ -121,6 +122,7 @@ const presentation = (
|
|
|
121
122
|
isFallback: skeleton.isFallback,
|
|
122
123
|
suggestions,
|
|
123
124
|
recId: skeleton.recId,
|
|
125
|
+
recIdStr: skeleton.recIdStr,
|
|
124
126
|
headers,
|
|
125
127
|
}
|
|
126
128
|
}
|
|
@@ -129,7 +131,6 @@ type Context = {
|
|
|
129
131
|
hydrator: Hydrator
|
|
130
132
|
views: Views
|
|
131
133
|
suggestionsAgent: AtpAgent | undefined
|
|
132
|
-
featureGates: AppContext['featureGates']
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
type Params = QueryParams & {
|
|
@@ -141,5 +142,6 @@ type SkeletonState = {
|
|
|
141
142
|
isFallback: boolean
|
|
142
143
|
suggestedDids: string[]
|
|
143
144
|
recId?: number
|
|
145
|
+
recIdStr?: string
|
|
144
146
|
headers?: HeadersMap
|
|
145
147
|
}
|
|
@@ -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'])
|
|
@@ -63,7 +72,7 @@ const skeletonFromDiscover = async (
|
|
|
63
72
|
throw new InternalServerError('Suggestions agent not available')
|
|
64
73
|
|
|
65
74
|
const res =
|
|
66
|
-
await ctx.suggestionsAgent.app.bsky.unspecced.
|
|
75
|
+
await ctx.suggestionsAgent.app.bsky.unspecced.getOnboardingSuggestedUsersSkeleton(
|
|
67
76
|
{
|
|
68
77
|
limit: params.limit,
|
|
69
78
|
viewer: params.hydrateCtx.viewer ?? undefined,
|
|
@@ -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
|
+
}
|