@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
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { featureGatesLogger } from '../logger'
|
|
2
|
+
|
|
3
|
+
type Events = {
|
|
4
|
+
'experiment:viewed': {
|
|
5
|
+
experimentId: string
|
|
6
|
+
variationId: string
|
|
7
|
+
}
|
|
8
|
+
'feature:viewed': {
|
|
9
|
+
featureId: string
|
|
10
|
+
featureResultValue: unknown
|
|
11
|
+
/** Only available if feature has experiment rules applied */
|
|
12
|
+
experimentId?: string
|
|
13
|
+
/** Only available if feature has experiment rules applied */
|
|
14
|
+
variationId?: string
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Event<M extends Record<string, any>> = {
|
|
19
|
+
time: number
|
|
20
|
+
event: keyof M
|
|
21
|
+
payload: M[keyof M]
|
|
22
|
+
metadata: Record<string, any>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type Config = {
|
|
26
|
+
trackingEndpoint?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class MetricsClient<M extends Record<string, any> = Events> {
|
|
30
|
+
maxBatchSize = 100
|
|
31
|
+
|
|
32
|
+
private started: boolean = false
|
|
33
|
+
private queue: Event<M>[] = []
|
|
34
|
+
private flushInterval: NodeJS.Timeout | null = null
|
|
35
|
+
constructor(private config: Config) {}
|
|
36
|
+
|
|
37
|
+
start() {
|
|
38
|
+
if (this.started) return
|
|
39
|
+
this.started = true
|
|
40
|
+
this.flushInterval = setInterval(() => {
|
|
41
|
+
this.flush()
|
|
42
|
+
}, 10_000)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
stop() {
|
|
46
|
+
if (this.flushInterval) {
|
|
47
|
+
clearInterval(this.flushInterval)
|
|
48
|
+
this.flushInterval = null
|
|
49
|
+
}
|
|
50
|
+
this.flush()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
track<E extends keyof M>(
|
|
54
|
+
event: E,
|
|
55
|
+
payload: M[E],
|
|
56
|
+
metadata: Record<string, any> = {},
|
|
57
|
+
) {
|
|
58
|
+
this.start()
|
|
59
|
+
|
|
60
|
+
const e = {
|
|
61
|
+
source: 'appview',
|
|
62
|
+
time: Date.now(),
|
|
63
|
+
event,
|
|
64
|
+
payload,
|
|
65
|
+
metadata,
|
|
66
|
+
}
|
|
67
|
+
this.queue.push(e)
|
|
68
|
+
|
|
69
|
+
if (this.queue.length > this.maxBatchSize) {
|
|
70
|
+
this.flush()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
flush() {
|
|
75
|
+
if (!this.queue.length) return
|
|
76
|
+
const events = this.queue.splice(0, this.queue.length)
|
|
77
|
+
this.sendBatch(events)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async sendBatch(events: Event<M>[]) {
|
|
81
|
+
if (!this.config.trackingEndpoint) return
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(this.config.trackingEndpoint, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: {
|
|
87
|
+
'Content-Type': 'application/json',
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({ events }),
|
|
90
|
+
keepalive: true,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
const errorText = await res.text().catch(() => 'Unknown error')
|
|
95
|
+
featureGatesLogger.error(
|
|
96
|
+
{ err: new Error(`${res.status} Failed to fetch - ${errorText}`) },
|
|
97
|
+
'Failed to send metrics',
|
|
98
|
+
)
|
|
99
|
+
} else {
|
|
100
|
+
// Drain response body to allow connection reuse.
|
|
101
|
+
await res.text().catch(() => {})
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
featureGatesLogger.error({ err }, 'Failed to send metrics')
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type express from 'express'
|
|
2
|
+
import { FeatureGate } from './gates'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The user context passed to the feature gates client for evaluation and
|
|
6
|
+
* tracking purposes.
|
|
7
|
+
*/
|
|
8
|
+
export type RawUserContext = {
|
|
9
|
+
/**
|
|
10
|
+
* The user's DID
|
|
11
|
+
*/
|
|
12
|
+
viewer: string | null
|
|
13
|
+
/**
|
|
14
|
+
* The express request object, used to extract analytics headers for the user context
|
|
15
|
+
*/
|
|
16
|
+
req: express.Request
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extracted values from the `RawUserContext`. These values should match the
|
|
21
|
+
* `attributes` we've configured for GrowthBook in our GB dashboard. We also
|
|
22
|
+
* send these same values as properties in our analytics events, so we want to
|
|
23
|
+
* make sure they are consistent.
|
|
24
|
+
*/
|
|
25
|
+
export type ParsedUserContext = {
|
|
26
|
+
did?: string | null
|
|
27
|
+
deviceId: string
|
|
28
|
+
sessionId: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* This loosely matches the metadata we send from the client for analytics
|
|
33
|
+
* events. We want to make sure we have the same properties in both places so
|
|
34
|
+
* that we can correlate feature gate evaluations with analytics events.
|
|
35
|
+
*
|
|
36
|
+
* @see https://github.com/bluesky-social/social-app/blob/76109a58dc7aafccdfbd07a81cbd9925e065d1c0/src/analytics/metadata.ts
|
|
37
|
+
*/
|
|
38
|
+
export type TrackingMetadata = {
|
|
39
|
+
base: {
|
|
40
|
+
deviceId: string
|
|
41
|
+
sessionId: string
|
|
42
|
+
}
|
|
43
|
+
session: {
|
|
44
|
+
did: string | undefined
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pre-evaluated feature gates map, the result of
|
|
50
|
+
* `ctx.FeatureGatesClient.checkGates()`
|
|
51
|
+
*/
|
|
52
|
+
export type CheckedFeatureGatesMap = Map<FeatureGate, boolean>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { type UserContext as GrowthBookUserContext } from '@growthbook/growthbook'
|
|
3
|
+
import { ParsedUserContext, RawUserContext, TrackingMetadata } from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* These need to match what the client sends
|
|
7
|
+
*/
|
|
8
|
+
const ANALYTICS_HEADER_DEVICE_ID = 'X-Bsky-Device-Id'
|
|
9
|
+
const ANALYTICS_HEADER_SESSION_ID = 'X-Bsky-Session-Id'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse the `RawUserContext` into a `ParsedUserContext` that is used as
|
|
13
|
+
* GrowthBook `attributes` as well as the metadata payload for our analytics
|
|
14
|
+
* events. This ensures that the same user properties are used for both feature
|
|
15
|
+
* gate targeting and analytics.
|
|
16
|
+
*/
|
|
17
|
+
export function parseRawUserContext(
|
|
18
|
+
userContext: RawUserContext,
|
|
19
|
+
): ParsedUserContext {
|
|
20
|
+
const did = userContext.viewer
|
|
21
|
+
|
|
22
|
+
// prioritize passthrough header
|
|
23
|
+
let deviceId = userContext.req.header(ANALYTICS_HEADER_DEVICE_ID)
|
|
24
|
+
if (!deviceId) {
|
|
25
|
+
if (did) {
|
|
26
|
+
/*
|
|
27
|
+
* If we don't have a device header, fall back to the DID. Our event
|
|
28
|
+
* proxy ensures ordering based on this deviceId (also called a stableId
|
|
29
|
+
* in the proxy), so if we have a DID, we want to use it to ensure client
|
|
30
|
+
* and server events are properly ordered.
|
|
31
|
+
*/
|
|
32
|
+
deviceId = did
|
|
33
|
+
} else {
|
|
34
|
+
/*
|
|
35
|
+
* Without any better option for identifying the user, we generate a
|
|
36
|
+
* random deviceId.
|
|
37
|
+
*/
|
|
38
|
+
deviceId = `anon-${crypto.randomUUID()}`
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// prioritize passthrough header
|
|
43
|
+
let sessionId = userContext.req.header(ANALYTICS_HEADER_SESSION_ID)
|
|
44
|
+
if (!sessionId) {
|
|
45
|
+
/*
|
|
46
|
+
* Without any better option for identifying the user, we generate a
|
|
47
|
+
* random deviceId.
|
|
48
|
+
*/
|
|
49
|
+
sessionId = `anon-${crypto.randomUUID()}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
did,
|
|
54
|
+
deviceId,
|
|
55
|
+
sessionId,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract the `ParsedUserContext` from the GrowthBook `UserContext`, which we
|
|
61
|
+
* passed into `isOn` as `attributes`.
|
|
62
|
+
*/
|
|
63
|
+
export function extractParsedUserContextFromGrowthBookUserContext(
|
|
64
|
+
userContext: GrowthBookUserContext,
|
|
65
|
+
): ParsedUserContext {
|
|
66
|
+
return {
|
|
67
|
+
did: userContext.attributes?.did,
|
|
68
|
+
deviceId: userContext.attributes?.deviceId,
|
|
69
|
+
sessionId: userContext.attributes?.sessionId,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Convert the `ParsedUserContext` into the `TrackingMetadata` format that we
|
|
75
|
+
* use for our analytics events. This ensures that we have the same user
|
|
76
|
+
* properties as we do for events from our client app.
|
|
77
|
+
*/
|
|
78
|
+
export function parsedUserContextToTrackingMetadata(
|
|
79
|
+
parsedUserContext: ParsedUserContext,
|
|
80
|
+
): TrackingMetadata {
|
|
81
|
+
return {
|
|
82
|
+
base: {
|
|
83
|
+
deviceId: parsedUserContext.deviceId,
|
|
84
|
+
sessionId: parsedUserContext.sessionId,
|
|
85
|
+
},
|
|
86
|
+
session: {
|
|
87
|
+
did: parsedUserContext.did ?? undefined,
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -2,7 +2,7 @@ import assert from 'node:assert'
|
|
|
2
2
|
import { mapDefined } from '@atproto/common'
|
|
3
3
|
import { AtUri } from '@atproto/syntax'
|
|
4
4
|
import { DataPlaneClient } from '../data-plane/client'
|
|
5
|
-
import { type CheckedFeatureGatesMap
|
|
5
|
+
import { type CheckedFeatureGatesMap } from '../feature-gates/types'
|
|
6
6
|
import { ids } from '../lexicon/lexicons'
|
|
7
7
|
import { Record as ProfileRecord } from '../lexicon/types/app/bsky/actor/profile'
|
|
8
8
|
import { isMain as isEmbedRecord } from '../lexicon/types/app/bsky/embed/record'
|
|
@@ -83,7 +83,13 @@ export class HydrateCtx {
|
|
|
83
83
|
overrideIncludeTakedownsForActor = this.vals.overrideIncludeTakedownsForActor
|
|
84
84
|
include3pBlocks = this.vals.include3pBlocks
|
|
85
85
|
includeDebugField = this.vals.includeDebugField
|
|
86
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Cache of evaluated feature gates to be used in a given request lifecycle.
|
|
88
|
+
* The actual evaluations happen at the top of the route handler and the
|
|
89
|
+
* results are stored in this map.
|
|
90
|
+
*/
|
|
91
|
+
featureGatesMap: CheckedFeatureGatesMap =
|
|
92
|
+
this.vals.featureGatesMap || new Map()
|
|
87
93
|
constructor(private vals: HydrateCtxVals) {}
|
|
88
94
|
// Convenience with use with dataplane.getActors cache control
|
|
89
95
|
get skipCacheForViewer() {
|
|
@@ -102,7 +108,7 @@ export type HydrateCtxVals = {
|
|
|
102
108
|
overrideIncludeTakedownsForActor?: boolean
|
|
103
109
|
include3pBlocks?: boolean
|
|
104
110
|
includeDebugField?: boolean
|
|
105
|
-
|
|
111
|
+
featureGatesMap?: CheckedFeatureGatesMap
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
export type HydrationState = {
|
|
@@ -746,8 +752,8 @@ export class Hydrator {
|
|
|
746
752
|
ctx: HydrateCtx,
|
|
747
753
|
): Promise<HydrationState> {
|
|
748
754
|
const postsState = await this.hydratePosts(refs, ctx, undefined, {
|
|
749
|
-
processDynamicTagsForView: ctx.
|
|
750
|
-
|
|
755
|
+
processDynamicTagsForView: ctx.featureGatesMap.get(
|
|
756
|
+
'threads:reply_ranking_exploration:enable',
|
|
751
757
|
)
|
|
752
758
|
? 'thread'
|
|
753
759
|
: undefined,
|
|
@@ -1331,7 +1337,7 @@ export class Hydrator {
|
|
|
1331
1337
|
includeTakedowns: vals.includeTakedowns,
|
|
1332
1338
|
include3pBlocks: vals.include3pBlocks,
|
|
1333
1339
|
includeDebugField,
|
|
1334
|
-
|
|
1340
|
+
featureGatesMap: vals.featureGatesMap,
|
|
1335
1341
|
})
|
|
1336
1342
|
}
|
|
1337
1343
|
|
package/src/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
createDataPlaneClient,
|
|
24
24
|
} from './data-plane/client'
|
|
25
25
|
import * as error from './error'
|
|
26
|
-
import {
|
|
26
|
+
import { FeatureGatesClient } from './feature-gates'
|
|
27
27
|
import { Hydrator } from './hydration/hydrator'
|
|
28
28
|
import * as imageServer from './image/server'
|
|
29
29
|
import { ImageUriBuilder } from './image/uri'
|
|
@@ -181,9 +181,10 @@ export class BskyAppView {
|
|
|
181
181
|
entrywayJwtPublicKey,
|
|
182
182
|
})
|
|
183
183
|
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
184
|
+
const featureGatesClient = new FeatureGatesClient({
|
|
185
|
+
growthBookApiHost: config.growthBookApiHost,
|
|
186
|
+
growthBookClientKey: config.growthBookClientKey,
|
|
187
|
+
eventProxyTrackingEndpoint: config.eventProxyTrackingEndpoint,
|
|
187
188
|
})
|
|
188
189
|
|
|
189
190
|
const blobDispatcher = createBlobDispatcher(config)
|
|
@@ -205,7 +206,7 @@ export class BskyAppView {
|
|
|
205
206
|
courierClient,
|
|
206
207
|
rolodexClient,
|
|
207
208
|
authVerifier,
|
|
208
|
-
|
|
209
|
+
featureGatesClient,
|
|
209
210
|
blobDispatcher,
|
|
210
211
|
kwsClient,
|
|
211
212
|
})
|
|
@@ -241,7 +242,7 @@ export class BskyAppView {
|
|
|
241
242
|
if (this.ctx.dataplaneHostList instanceof EtcdHostList) {
|
|
242
243
|
await this.ctx.dataplaneHostList.connect()
|
|
243
244
|
}
|
|
244
|
-
|
|
245
|
+
this.ctx.featureGatesClient.start() // lazy, no await
|
|
245
246
|
const server = this.app.listen(this.ctx.cfg.port)
|
|
246
247
|
this.server = server
|
|
247
248
|
server.keepAliveTimeout = 90000
|
|
@@ -253,7 +254,7 @@ export class BskyAppView {
|
|
|
253
254
|
}
|
|
254
255
|
|
|
255
256
|
async destroy(): Promise<void> {
|
|
256
|
-
this.ctx.
|
|
257
|
+
this.ctx.featureGatesClient.destroy()
|
|
257
258
|
await this.terminator?.terminate()
|
|
258
259
|
await this.ctx.etcd?.close()
|
|
259
260
|
}
|
package/src/lexicon/index.ts
CHANGED
|
@@ -91,13 +91,13 @@ import * as AppBskyUnspeccedGetAgeAssuranceState from './types/app/bsky/unspecce
|
|
|
91
91
|
import * as AppBskyUnspeccedGetConfig from './types/app/bsky/unspecced/getConfig.js'
|
|
92
92
|
import * as AppBskyUnspeccedGetOnboardingSuggestedStarterPacks from './types/app/bsky/unspecced/getOnboardingSuggestedStarterPacks.js'
|
|
93
93
|
import * as AppBskyUnspeccedGetOnboardingSuggestedStarterPacksSkeleton from './types/app/bsky/unspecced/getOnboardingSuggestedStarterPacksSkeleton.js'
|
|
94
|
+
import * as AppBskyUnspeccedGetOnboardingSuggestedUsersSkeleton from './types/app/bsky/unspecced/getOnboardingSuggestedUsersSkeleton.js'
|
|
94
95
|
import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators.js'
|
|
95
96
|
import * as AppBskyUnspeccedGetPostThreadOtherV2 from './types/app/bsky/unspecced/getPostThreadOtherV2.js'
|
|
96
97
|
import * as AppBskyUnspeccedGetPostThreadV2 from './types/app/bsky/unspecced/getPostThreadV2.js'
|
|
97
98
|
import * as AppBskyUnspeccedGetSuggestedFeeds from './types/app/bsky/unspecced/getSuggestedFeeds.js'
|
|
98
99
|
import * as AppBskyUnspeccedGetSuggestedFeedsSkeleton from './types/app/bsky/unspecced/getSuggestedFeedsSkeleton.js'
|
|
99
100
|
import * as AppBskyUnspeccedGetSuggestedOnboardingUsers from './types/app/bsky/unspecced/getSuggestedOnboardingUsers.js'
|
|
100
|
-
import * as AppBskyUnspeccedGetSuggestedOnboardingUsersSkeleton from './types/app/bsky/unspecced/getSuggestedOnboardingUsersSkeleton.js'
|
|
101
101
|
import * as AppBskyUnspeccedGetSuggestedStarterPacks from './types/app/bsky/unspecced/getSuggestedStarterPacks.js'
|
|
102
102
|
import * as AppBskyUnspeccedGetSuggestedStarterPacksSkeleton from './types/app/bsky/unspecced/getSuggestedStarterPacksSkeleton.js'
|
|
103
103
|
import * as AppBskyUnspeccedGetSuggestedUsers from './types/app/bsky/unspecced/getSuggestedUsers.js'
|
|
@@ -1386,6 +1386,18 @@ export class AppBskyUnspeccedNS {
|
|
|
1386
1386
|
return this._server.xrpc.method(nsid, cfg)
|
|
1387
1387
|
}
|
|
1388
1388
|
|
|
1389
|
+
getOnboardingSuggestedUsersSkeleton<A extends Auth = void>(
|
|
1390
|
+
cfg: MethodConfigOrHandler<
|
|
1391
|
+
A,
|
|
1392
|
+
AppBskyUnspeccedGetOnboardingSuggestedUsersSkeleton.QueryParams,
|
|
1393
|
+
AppBskyUnspeccedGetOnboardingSuggestedUsersSkeleton.HandlerInput,
|
|
1394
|
+
AppBskyUnspeccedGetOnboardingSuggestedUsersSkeleton.HandlerOutput
|
|
1395
|
+
>,
|
|
1396
|
+
) {
|
|
1397
|
+
const nsid = 'app.bsky.unspecced.getOnboardingSuggestedUsersSkeleton' // @ts-ignore
|
|
1398
|
+
return this._server.xrpc.method(nsid, cfg)
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1389
1401
|
getPopularFeedGenerators<A extends Auth = void>(
|
|
1390
1402
|
cfg: MethodConfigOrHandler<
|
|
1391
1403
|
A,
|
|
@@ -1458,18 +1470,6 @@ export class AppBskyUnspeccedNS {
|
|
|
1458
1470
|
return this._server.xrpc.method(nsid, cfg)
|
|
1459
1471
|
}
|
|
1460
1472
|
|
|
1461
|
-
getSuggestedOnboardingUsersSkeleton<A extends Auth = void>(
|
|
1462
|
-
cfg: MethodConfigOrHandler<
|
|
1463
|
-
A,
|
|
1464
|
-
AppBskyUnspeccedGetSuggestedOnboardingUsersSkeleton.QueryParams,
|
|
1465
|
-
AppBskyUnspeccedGetSuggestedOnboardingUsersSkeleton.HandlerInput,
|
|
1466
|
-
AppBskyUnspeccedGetSuggestedOnboardingUsersSkeleton.HandlerOutput
|
|
1467
|
-
>,
|
|
1468
|
-
) {
|
|
1469
|
-
const nsid = 'app.bsky.unspecced.getSuggestedOnboardingUsersSkeleton' // @ts-ignore
|
|
1470
|
-
return this._server.xrpc.method(nsid, cfg)
|
|
1471
|
-
}
|
|
1472
|
-
|
|
1473
1473
|
getSuggestedStarterPacks<A extends Auth = void>(
|
|
1474
1474
|
cfg: MethodConfigOrHandler<
|
|
1475
1475
|
A,
|