@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.
Files changed (80) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/api/app/bsky/feed/searchPosts.d.ts.map +1 -1
  3. package/dist/api/app/bsky/feed/searchPosts.js +6 -4
  4. package/dist/api/app/bsky/feed/searchPosts.js.map +1 -1
  5. package/dist/api/app/bsky/graph/getSuggestedFollowsByActor.js.map +1 -1
  6. package/dist/api/app/bsky/unspecced/getPostThreadV2.js +1 -1
  7. package/dist/api/app/bsky/unspecced/getPostThreadV2.js.map +1 -1
  8. package/dist/api/app/bsky/unspecced/getSuggestedOnboardingUsers.d.ts.map +1 -1
  9. package/dist/api/app/bsky/unspecced/getSuggestedOnboardingUsers.js +9 -2
  10. package/dist/api/app/bsky/unspecced/getSuggestedOnboardingUsers.js.map +1 -1
  11. package/dist/api/app/bsky/unspecced/getSuggestedUsers.d.ts.map +1 -1
  12. package/dist/api/app/bsky/unspecced/getSuggestedUsers.js +9 -2
  13. package/dist/api/app/bsky/unspecced/getSuggestedUsers.js.map +1 -1
  14. package/dist/config.d.ts +2 -0
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +5 -0
  17. package/dist/config.js.map +1 -1
  18. package/dist/context.d.ts +3 -3
  19. package/dist/context.d.ts.map +1 -1
  20. package/dist/context.js +2 -2
  21. package/dist/context.js.map +1 -1
  22. package/dist/feature-gates/gates.d.ts +5 -0
  23. package/dist/feature-gates/gates.d.ts.map +1 -0
  24. package/dist/feature-gates/gates.js +6 -0
  25. package/dist/feature-gates/gates.js.map +1 -0
  26. package/dist/feature-gates/index.d.ts +24 -0
  27. package/dist/feature-gates/index.d.ts.map +1 -0
  28. package/dist/feature-gates/index.js +135 -0
  29. package/dist/feature-gates/index.js.map +1 -0
  30. package/dist/feature-gates/metrics.d.ts +32 -0
  31. package/dist/feature-gates/metrics.d.ts.map +1 -0
  32. package/dist/feature-gates/metrics.js +100 -0
  33. package/dist/feature-gates/metrics.js.map +1 -0
  34. package/dist/feature-gates/metrics.test.d.ts +2 -0
  35. package/dist/feature-gates/metrics.test.d.ts.map +1 -0
  36. package/dist/feature-gates/metrics.test.js +152 -0
  37. package/dist/feature-gates/metrics.test.js.map +1 -0
  38. package/dist/feature-gates/types.d.ts +49 -0
  39. package/dist/feature-gates/types.d.ts.map +1 -0
  40. package/dist/feature-gates/types.js +3 -0
  41. package/dist/feature-gates/types.js.map +1 -0
  42. package/dist/feature-gates/utils.d.ts +21 -0
  43. package/dist/feature-gates/utils.d.ts.map +1 -0
  44. package/dist/feature-gates/utils.js +85 -0
  45. package/dist/feature-gates/utils.js.map +1 -0
  46. package/dist/hydration/hydrator.d.ts +8 -3
  47. package/dist/hydration/hydrator.d.ts.map +1 -1
  48. package/dist/hydration/hydrator.js +9 -5
  49. package/dist/hydration/hydrator.js.map +1 -1
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +7 -6
  52. package/dist/index.js.map +1 -1
  53. package/dist/views/index.d.ts.map +1 -1
  54. package/dist/views/index.js +3 -4
  55. package/dist/views/index.js.map +1 -1
  56. package/package.json +10 -10
  57. package/src/api/app/bsky/feed/searchPosts.ts +10 -8
  58. package/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +0 -1
  59. package/src/api/app/bsky/unspecced/getPostThreadV2.ts +3 -3
  60. package/src/api/app/bsky/unspecced/getSuggestedOnboardingUsers.ts +13 -6
  61. package/src/api/app/bsky/unspecced/getSuggestedUsers.ts +13 -6
  62. package/src/config.ts +8 -0
  63. package/src/context.ts +4 -4
  64. package/src/feature-gates/README.md +47 -0
  65. package/src/feature-gates/gates.ts +9 -0
  66. package/src/feature-gates/index.ts +146 -0
  67. package/src/feature-gates/metrics.test.ts +196 -0
  68. package/src/feature-gates/metrics.ts +107 -0
  69. package/src/feature-gates/types.ts +52 -0
  70. package/src/feature-gates/utils.ts +90 -0
  71. package/src/hydration/hydrator.ts +12 -6
  72. package/src/index.ts +8 -7
  73. package/src/views/index.ts +5 -8
  74. package/tests/views/thread.test.ts +2 -0
  75. package/tsconfig.build.tsbuildinfo +1 -1
  76. package/dist/feature-gates.d.ts +0 -44
  77. package/dist/feature-gates.d.ts.map +0 -1
  78. package/dist/feature-gates.js +0 -133
  79. package/dist/feature-gates.js.map +0 -1
  80. 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.216",
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/api": "^0.18.21",
57
- "@atproto/common": "^0.5.11",
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/xrpc-server": "^0.10.12"
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.18.21",
81
+ "@atproto/api": "^0.19.0",
82
82
  "@atproto/lex-cli": "^0.9.8",
83
- "@atproto/pds": "^0.4.209",
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
- featureGates: ctx.featureGates.checkGates(
43
- [ctx.featureGates.ids.SearchFilteringExplorationEnable],
44
- ctx.featureGates.userContext({ did: viewer }),
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.featureGates.get(
113
- FeatureGateID.SearchFilteringExplorationEnable,
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.featureGates.get(
143
- FeatureGateID.SearchFilteringExplorationEnable,
144
+ params.hydrateCtx.featureGatesMap.get(
145
+ 'search:filtering_exploration:enable',
144
146
  )
145
147
  ) {
146
148
  tagged = post.tags.has(ctx.cfg.visibilityTagHide)
@@ -131,7 +131,6 @@ type Context = {
131
131
  hydrator: Hydrator
132
132
  views: Views
133
133
  suggestionsAgent: AtpAgent | undefined
134
- featureGates: AppContext['featureGates']
135
134
  }
136
135
 
137
136
  type Params = QueryParams & {
@@ -33,9 +33,9 @@ export default function (server: Server, ctx: AppContext) {
33
33
  viewer,
34
34
  includeTakedowns,
35
35
  include3pBlocks,
36
- featureGates: ctx.featureGates.checkGates(
37
- [ctx.featureGates.ids.ThreadsReplyRankingExplorationEnable],
38
- ctx.featureGates.userContext({ did: viewer }),
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({ labelers, viewer })
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.ctx.featureGates.check(
104
- input.ctx.featureGates.ids.SuggestedOnboardingUsersDiscoverAgentEnable,
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({ labelers, viewer })
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.ctx.featureGates.check(
102
- input.ctx.featureGates.ids.SuggestedUsersDiscoverAgentEnable,
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 { FeatureGates } from './feature-gates'
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
- featureGates: FeatureGates
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 featureGates(): FeatureGates {
120
- return this.opts.featureGates
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,9 @@
1
+ /**
2
+ * ADD ALL FEATURE GATES HERE.
3
+ */
4
+
5
+ export type FeatureGate =
6
+ | 'suggested_users:discover_agent:enable'
7
+ | 'suggested_onboarding_users:discover_agent:enable'
8
+ | 'threads:reply_ranking_exploration:enable'
9
+ | 'search:filtering_exploration:enable'
@@ -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
+ })