@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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/// <reference types="jest" />
|
|
4
|
+
const logger_1 = require("../logger");
|
|
5
|
+
const metrics_1 = require("./metrics");
|
|
6
|
+
jest.mock('../logger', () => ({
|
|
7
|
+
featureGatesLogger: {
|
|
8
|
+
error: jest.fn(),
|
|
9
|
+
},
|
|
10
|
+
}));
|
|
11
|
+
// Helper to flush promises and timers
|
|
12
|
+
const flushPromises = () => new Promise((r) => setImmediate(r));
|
|
13
|
+
describe('MetricsClient', () => {
|
|
14
|
+
let fetchMock;
|
|
15
|
+
let fetchRequests;
|
|
16
|
+
let client;
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
jest.useFakeTimers({ doNotFake: ['setImmediate', 'performance'] });
|
|
19
|
+
fetchRequests = [];
|
|
20
|
+
fetchMock = jest.fn().mockImplementation(async (_url, options) => {
|
|
21
|
+
const body = JSON.parse(options.body);
|
|
22
|
+
fetchRequests.push({ body });
|
|
23
|
+
return { ok: true, status: 200, text: async () => '' };
|
|
24
|
+
});
|
|
25
|
+
global.fetch = fetchMock;
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
client?.stop();
|
|
29
|
+
jest.useRealTimers();
|
|
30
|
+
jest.clearAllMocks();
|
|
31
|
+
});
|
|
32
|
+
it('flushes events on interval', async () => {
|
|
33
|
+
client = new metrics_1.MetricsClient({
|
|
34
|
+
trackingEndpoint: 'https://test.metrics.api',
|
|
35
|
+
});
|
|
36
|
+
client.track('click', { button: 'submit' });
|
|
37
|
+
client.track('view', { screen: 'home' });
|
|
38
|
+
expect(fetchRequests).toHaveLength(0);
|
|
39
|
+
// Advance past the 10 second interval
|
|
40
|
+
jest.advanceTimersByTime(10000);
|
|
41
|
+
await flushPromises();
|
|
42
|
+
expect(fetchRequests).toHaveLength(1);
|
|
43
|
+
expect(fetchRequests[0].body.events).toHaveLength(2);
|
|
44
|
+
expect(fetchRequests[0].body.events[0].event).toBe('click');
|
|
45
|
+
expect(fetchRequests[0].body.events[1].event).toBe('view');
|
|
46
|
+
});
|
|
47
|
+
it('flushes when maxBatchSize is exceeded', async () => {
|
|
48
|
+
client = new metrics_1.MetricsClient({
|
|
49
|
+
trackingEndpoint: 'https://test.metrics.api',
|
|
50
|
+
});
|
|
51
|
+
client.maxBatchSize = 5;
|
|
52
|
+
// Add events up to maxBatchSize (should not flush yet)
|
|
53
|
+
for (let i = 0; i < 5; i++) {
|
|
54
|
+
client.track('click', { button: `btn-${i}` });
|
|
55
|
+
}
|
|
56
|
+
expect(fetchRequests).toHaveLength(0);
|
|
57
|
+
// One more event should trigger flush (> maxBatchSize)
|
|
58
|
+
client.track('click', { button: 'btn-trigger' });
|
|
59
|
+
await flushPromises();
|
|
60
|
+
expect(fetchRequests).toHaveLength(1);
|
|
61
|
+
expect(fetchRequests[0].body.events).toHaveLength(6);
|
|
62
|
+
});
|
|
63
|
+
it('logs error on failed request', async () => {
|
|
64
|
+
fetchMock.mockImplementation(async () => {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
status: 500,
|
|
68
|
+
text: async () => 'Internal Server Error',
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
client = new metrics_1.MetricsClient({
|
|
72
|
+
trackingEndpoint: 'https://test.metrics.api',
|
|
73
|
+
});
|
|
74
|
+
client.track('click', { button: 'submit' });
|
|
75
|
+
// Trigger flush via interval
|
|
76
|
+
jest.advanceTimersByTime(10000);
|
|
77
|
+
await flushPromises();
|
|
78
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
79
|
+
expect(logger_1.featureGatesLogger.error).toHaveBeenCalledWith(expect.objectContaining({
|
|
80
|
+
err: expect.any(Error),
|
|
81
|
+
}), 'Failed to send metrics');
|
|
82
|
+
});
|
|
83
|
+
it('handles fetch text() error gracefully', async () => {
|
|
84
|
+
fetchMock.mockImplementation(async () => {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
status: 500,
|
|
88
|
+
text: async () => {
|
|
89
|
+
throw new Error('Failed to read response');
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
client = new metrics_1.MetricsClient({
|
|
94
|
+
trackingEndpoint: 'https://test.metrics.api',
|
|
95
|
+
});
|
|
96
|
+
client.track('click', { button: 'submit' });
|
|
97
|
+
// Trigger flush - should not throw
|
|
98
|
+
jest.advanceTimersByTime(10000);
|
|
99
|
+
await flushPromises();
|
|
100
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
101
|
+
expect(logger_1.featureGatesLogger.error).toHaveBeenCalledWith(expect.objectContaining({
|
|
102
|
+
err: expect.objectContaining({
|
|
103
|
+
message: expect.stringContaining('Unknown error'),
|
|
104
|
+
}),
|
|
105
|
+
}), 'Failed to send metrics');
|
|
106
|
+
});
|
|
107
|
+
it('flushes when stop() is called', async () => {
|
|
108
|
+
client = new metrics_1.MetricsClient({
|
|
109
|
+
trackingEndpoint: 'https://test.metrics.api',
|
|
110
|
+
});
|
|
111
|
+
client.track('click', { button: 'submit' });
|
|
112
|
+
expect(fetchRequests).toHaveLength(0);
|
|
113
|
+
// Stop should flush remaining events
|
|
114
|
+
client.stop();
|
|
115
|
+
await flushPromises();
|
|
116
|
+
expect(fetchRequests).toHaveLength(1);
|
|
117
|
+
expect(fetchRequests[0].body.events).toHaveLength(1);
|
|
118
|
+
expect(fetchRequests[0].body.events[0].event).toBe('click');
|
|
119
|
+
});
|
|
120
|
+
it('does not send if trackingEndpoint is not configured', async () => {
|
|
121
|
+
client = new metrics_1.MetricsClient({});
|
|
122
|
+
client.track('click', { button: 'submit' });
|
|
123
|
+
// Trigger flush via interval
|
|
124
|
+
jest.advanceTimersByTime(10000);
|
|
125
|
+
await flushPromises();
|
|
126
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
it('start() is idempotent', async () => {
|
|
129
|
+
client = new metrics_1.MetricsClient({
|
|
130
|
+
trackingEndpoint: 'https://test.metrics.api',
|
|
131
|
+
});
|
|
132
|
+
// track() calls start() internally
|
|
133
|
+
client.track('click', { button: 'submit' });
|
|
134
|
+
client.start();
|
|
135
|
+
client.start();
|
|
136
|
+
// Advance past interval - should only flush once
|
|
137
|
+
jest.advanceTimersByTime(10000);
|
|
138
|
+
await flushPromises();
|
|
139
|
+
expect(fetchRequests).toHaveLength(1);
|
|
140
|
+
});
|
|
141
|
+
it('does not flush if queue is empty', async () => {
|
|
142
|
+
client = new metrics_1.MetricsClient({
|
|
143
|
+
trackingEndpoint: 'https://test.metrics.api',
|
|
144
|
+
});
|
|
145
|
+
client.start();
|
|
146
|
+
// Advance past interval with empty queue
|
|
147
|
+
jest.advanceTimersByTime(10000);
|
|
148
|
+
await flushPromises();
|
|
149
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
//# sourceMappingURL=metrics.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metrics.test.js","sourceRoot":"","sources":["../../src/feature-gates/metrics.test.ts"],"names":[],"mappings":"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","sourcesContent":["/// <reference types=\"jest\" />\nimport { featureGatesLogger } from '../logger'\nimport { MetricsClient } from './metrics'\n\njest.mock('../logger', () => ({\n featureGatesLogger: {\n error: jest.fn(),\n },\n}))\n\ntype TestEvents = {\n click: { button: string }\n view: { screen: string }\n}\n\n// Helper to flush promises and timers\nconst flushPromises = () => new Promise((r) => setImmediate(r))\n\ndescribe('MetricsClient', () => {\n let fetchMock: jest.Mock\n let fetchRequests: { body: any }[]\n let client: MetricsClient<TestEvents>\n\n beforeEach(() => {\n jest.useFakeTimers({ doNotFake: ['setImmediate', 'performance'] })\n fetchRequests = []\n fetchMock = jest.fn().mockImplementation(async (_url, options) => {\n const body = JSON.parse(options.body)\n fetchRequests.push({ body })\n return { ok: true, status: 200, text: async () => '' }\n })\n global.fetch = fetchMock\n })\n\n afterEach(() => {\n client?.stop()\n jest.useRealTimers()\n jest.clearAllMocks()\n })\n\n it('flushes events on interval', async () => {\n client = new MetricsClient<TestEvents>({\n trackingEndpoint: 'https://test.metrics.api',\n })\n client.track('click', { button: 'submit' })\n client.track('view', { screen: 'home' })\n\n expect(fetchRequests).toHaveLength(0)\n\n // Advance past the 10 second interval\n jest.advanceTimersByTime(10_000)\n await flushPromises()\n\n expect(fetchRequests).toHaveLength(1)\n expect(fetchRequests[0].body.events).toHaveLength(2)\n expect(fetchRequests[0].body.events[0].event).toBe('click')\n expect(fetchRequests[0].body.events[1].event).toBe('view')\n })\n\n it('flushes when maxBatchSize is exceeded', async () => {\n client = new MetricsClient<TestEvents>({\n trackingEndpoint: 'https://test.metrics.api',\n })\n client.maxBatchSize = 5\n\n // Add events up to maxBatchSize (should not flush yet)\n for (let i = 0; i < 5; i++) {\n client.track('click', { button: `btn-${i}` })\n }\n\n expect(fetchRequests).toHaveLength(0)\n\n // One more event should trigger flush (> maxBatchSize)\n client.track('click', { button: 'btn-trigger' })\n await flushPromises()\n\n expect(fetchRequests).toHaveLength(1)\n expect(fetchRequests[0].body.events).toHaveLength(6)\n })\n\n it('logs error on failed request', async () => {\n fetchMock.mockImplementation(async () => {\n return {\n ok: false,\n status: 500,\n text: async () => 'Internal Server Error',\n }\n })\n\n client = new MetricsClient<TestEvents>({\n trackingEndpoint: 'https://test.metrics.api',\n })\n client.track('click', { button: 'submit' })\n\n // Trigger flush via interval\n jest.advanceTimersByTime(10_000)\n await flushPromises()\n\n expect(fetchMock).toHaveBeenCalledTimes(1)\n expect(featureGatesLogger.error).toHaveBeenCalledWith(\n expect.objectContaining({\n err: expect.any(Error),\n }),\n 'Failed to send metrics',\n )\n })\n\n it('handles fetch text() error gracefully', async () => {\n fetchMock.mockImplementation(async () => {\n return {\n ok: false,\n status: 500,\n text: async () => {\n throw new Error('Failed to read response')\n },\n }\n })\n\n client = new MetricsClient<TestEvents>({\n trackingEndpoint: 'https://test.metrics.api',\n })\n client.track('click', { button: 'submit' })\n\n // Trigger flush - should not throw\n jest.advanceTimersByTime(10_000)\n await flushPromises()\n\n expect(fetchMock).toHaveBeenCalledTimes(1)\n expect(featureGatesLogger.error).toHaveBeenCalledWith(\n expect.objectContaining({\n err: expect.objectContaining({\n message: expect.stringContaining('Unknown error'),\n }),\n }),\n 'Failed to send metrics',\n )\n })\n\n it('flushes when stop() is called', async () => {\n client = new MetricsClient<TestEvents>({\n trackingEndpoint: 'https://test.metrics.api',\n })\n client.track('click', { button: 'submit' })\n\n expect(fetchRequests).toHaveLength(0)\n\n // Stop should flush remaining events\n client.stop()\n await flushPromises()\n\n expect(fetchRequests).toHaveLength(1)\n expect(fetchRequests[0].body.events).toHaveLength(1)\n expect(fetchRequests[0].body.events[0].event).toBe('click')\n })\n\n it('does not send if trackingEndpoint is not configured', async () => {\n client = new MetricsClient<TestEvents>({})\n client.track('click', { button: 'submit' })\n\n // Trigger flush via interval\n jest.advanceTimersByTime(10_000)\n await flushPromises()\n\n expect(fetchMock).not.toHaveBeenCalled()\n })\n\n it('start() is idempotent', async () => {\n client = new MetricsClient<TestEvents>({\n trackingEndpoint: 'https://test.metrics.api',\n })\n\n // track() calls start() internally\n client.track('click', { button: 'submit' })\n client.start()\n client.start()\n\n // Advance past interval - should only flush once\n jest.advanceTimersByTime(10_000)\n await flushPromises()\n\n expect(fetchRequests).toHaveLength(1)\n })\n\n it('does not flush if queue is empty', async () => {\n client = new MetricsClient<TestEvents>({\n trackingEndpoint: 'https://test.metrics.api',\n })\n client.start()\n\n // Advance past interval with empty queue\n jest.advanceTimersByTime(10_000)\n await flushPromises()\n\n expect(fetchMock).not.toHaveBeenCalled()\n })\n})\n"]}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type express from 'express';
|
|
2
|
+
import { FeatureGate } from './gates';
|
|
3
|
+
/**
|
|
4
|
+
* The user context passed to the feature gates client for evaluation and
|
|
5
|
+
* tracking purposes.
|
|
6
|
+
*/
|
|
7
|
+
export type RawUserContext = {
|
|
8
|
+
/**
|
|
9
|
+
* The user's DID
|
|
10
|
+
*/
|
|
11
|
+
viewer: string | null;
|
|
12
|
+
/**
|
|
13
|
+
* The express request object, used to extract analytics headers for the user context
|
|
14
|
+
*/
|
|
15
|
+
req: express.Request;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Extracted values from the `RawUserContext`. These values should match the
|
|
19
|
+
* `attributes` we've configured for GrowthBook in our GB dashboard. We also
|
|
20
|
+
* send these same values as properties in our analytics events, so we want to
|
|
21
|
+
* make sure they are consistent.
|
|
22
|
+
*/
|
|
23
|
+
export type ParsedUserContext = {
|
|
24
|
+
did?: string | null;
|
|
25
|
+
deviceId: string;
|
|
26
|
+
sessionId: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* This loosely matches the metadata we send from the client for analytics
|
|
30
|
+
* events. We want to make sure we have the same properties in both places so
|
|
31
|
+
* that we can correlate feature gate evaluations with analytics events.
|
|
32
|
+
*
|
|
33
|
+
* @see https://github.com/bluesky-social/social-app/blob/76109a58dc7aafccdfbd07a81cbd9925e065d1c0/src/analytics/metadata.ts
|
|
34
|
+
*/
|
|
35
|
+
export type TrackingMetadata = {
|
|
36
|
+
base: {
|
|
37
|
+
deviceId: string;
|
|
38
|
+
sessionId: string;
|
|
39
|
+
};
|
|
40
|
+
session: {
|
|
41
|
+
did: string | undefined;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Pre-evaluated feature gates map, the result of
|
|
46
|
+
* `ctx.FeatureGatesClient.checkGates()`
|
|
47
|
+
*/
|
|
48
|
+
export type CheckedFeatureGatesMap = Map<FeatureGate, boolean>;
|
|
49
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/feature-gates/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,OAAO,MAAM,SAAS,CAAA;AAClC,OAAO,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAErC;;;GAGG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B;;OAEG;IACH,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB;;OAEG;IACH,GAAG,EAAE,OAAO,CAAC,OAAO,CAAA;CACrB,CAAA;AAED;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED;;;;;;GAMG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,EAAE;QACJ,QAAQ,EAAE,MAAM,CAAA;QAChB,SAAS,EAAE,MAAM,CAAA;KAClB,CAAA;IACD,OAAO,EAAE;QACP,GAAG,EAAE,MAAM,GAAG,SAAS,CAAA;KACxB,CAAA;CACF,CAAA;AAED;;;GAGG;AACH,MAAM,MAAM,sBAAsB,GAAG,GAAG,CAAC,WAAW,EAAE,OAAO,CAAC,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/feature-gates/types.ts"],"names":[],"mappings":"","sourcesContent":["import type express from 'express'\nimport { FeatureGate } from './gates'\n\n/**\n * The user context passed to the feature gates client for evaluation and\n * tracking purposes.\n */\nexport type RawUserContext = {\n /**\n * The user's DID\n */\n viewer: string | null\n /**\n * The express request object, used to extract analytics headers for the user context\n */\n req: express.Request\n}\n\n/**\n * Extracted values from the `RawUserContext`. These values should match the\n * `attributes` we've configured for GrowthBook in our GB dashboard. We also\n * send these same values as properties in our analytics events, so we want to\n * make sure they are consistent.\n */\nexport type ParsedUserContext = {\n did?: string | null\n deviceId: string\n sessionId: string\n}\n\n/**\n * This loosely matches the metadata we send from the client for analytics\n * events. We want to make sure we have the same properties in both places so\n * that we can correlate feature gate evaluations with analytics events.\n *\n * @see https://github.com/bluesky-social/social-app/blob/76109a58dc7aafccdfbd07a81cbd9925e065d1c0/src/analytics/metadata.ts\n */\nexport type TrackingMetadata = {\n base: {\n deviceId: string\n sessionId: string\n }\n session: {\n did: string | undefined\n }\n}\n\n/**\n * Pre-evaluated feature gates map, the result of\n * `ctx.FeatureGatesClient.checkGates()`\n */\nexport type CheckedFeatureGatesMap = Map<FeatureGate, boolean>\n"]}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type UserContext as GrowthBookUserContext } from '@growthbook/growthbook';
|
|
2
|
+
import { ParsedUserContext, RawUserContext, TrackingMetadata } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Parse the `RawUserContext` into a `ParsedUserContext` that is used as
|
|
5
|
+
* GrowthBook `attributes` as well as the metadata payload for our analytics
|
|
6
|
+
* events. This ensures that the same user properties are used for both feature
|
|
7
|
+
* gate targeting and analytics.
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseRawUserContext(userContext: RawUserContext): ParsedUserContext;
|
|
10
|
+
/**
|
|
11
|
+
* Extract the `ParsedUserContext` from the GrowthBook `UserContext`, which we
|
|
12
|
+
* passed into `isOn` as `attributes`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function extractParsedUserContextFromGrowthBookUserContext(userContext: GrowthBookUserContext): ParsedUserContext;
|
|
15
|
+
/**
|
|
16
|
+
* Convert the `ParsedUserContext` into the `TrackingMetadata` format that we
|
|
17
|
+
* use for our analytics events. This ensures that we have the same user
|
|
18
|
+
* properties as we do for events from our client app.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parsedUserContextToTrackingMetadata(parsedUserContext: ParsedUserContext): TrackingMetadata;
|
|
21
|
+
//# sourceMappingURL=utils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/feature-gates/utils.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,WAAW,IAAI,qBAAqB,EAAE,MAAM,wBAAwB,CAAA;AAClF,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAQ7E;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,cAAc,GAC1B,iBAAiB,CAsCnB;AAED;;;GAGG;AACH,wBAAgB,iDAAiD,CAC/D,WAAW,EAAE,qBAAqB,GACjC,iBAAiB,CAMnB;AAED;;;;GAIG;AACH,wBAAgB,mCAAmC,CACjD,iBAAiB,EAAE,iBAAiB,GACnC,gBAAgB,CAUlB"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.parseRawUserContext = parseRawUserContext;
|
|
7
|
+
exports.extractParsedUserContextFromGrowthBookUserContext = extractParsedUserContextFromGrowthBookUserContext;
|
|
8
|
+
exports.parsedUserContextToTrackingMetadata = parsedUserContextToTrackingMetadata;
|
|
9
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
10
|
+
/**
|
|
11
|
+
* These need to match what the client sends
|
|
12
|
+
*/
|
|
13
|
+
const ANALYTICS_HEADER_DEVICE_ID = 'X-Bsky-Device-Id';
|
|
14
|
+
const ANALYTICS_HEADER_SESSION_ID = 'X-Bsky-Session-Id';
|
|
15
|
+
/**
|
|
16
|
+
* Parse the `RawUserContext` into a `ParsedUserContext` that is used as
|
|
17
|
+
* GrowthBook `attributes` as well as the metadata payload for our analytics
|
|
18
|
+
* events. This ensures that the same user properties are used for both feature
|
|
19
|
+
* gate targeting and analytics.
|
|
20
|
+
*/
|
|
21
|
+
function parseRawUserContext(userContext) {
|
|
22
|
+
const did = userContext.viewer;
|
|
23
|
+
// prioritize passthrough header
|
|
24
|
+
let deviceId = userContext.req.header(ANALYTICS_HEADER_DEVICE_ID);
|
|
25
|
+
if (!deviceId) {
|
|
26
|
+
if (did) {
|
|
27
|
+
/*
|
|
28
|
+
* If we don't have a device header, fall back to the DID. Our event
|
|
29
|
+
* proxy ensures ordering based on this deviceId (also called a stableId
|
|
30
|
+
* in the proxy), so if we have a DID, we want to use it to ensure client
|
|
31
|
+
* and server events are properly ordered.
|
|
32
|
+
*/
|
|
33
|
+
deviceId = did;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
/*
|
|
37
|
+
* Without any better option for identifying the user, we generate a
|
|
38
|
+
* random deviceId.
|
|
39
|
+
*/
|
|
40
|
+
deviceId = `anon-${node_crypto_1.default.randomUUID()}`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// prioritize passthrough header
|
|
44
|
+
let sessionId = userContext.req.header(ANALYTICS_HEADER_SESSION_ID);
|
|
45
|
+
if (!sessionId) {
|
|
46
|
+
/*
|
|
47
|
+
* Without any better option for identifying the user, we generate a
|
|
48
|
+
* random deviceId.
|
|
49
|
+
*/
|
|
50
|
+
sessionId = `anon-${node_crypto_1.default.randomUUID()}`;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
did,
|
|
54
|
+
deviceId,
|
|
55
|
+
sessionId,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Extract the `ParsedUserContext` from the GrowthBook `UserContext`, which we
|
|
60
|
+
* passed into `isOn` as `attributes`.
|
|
61
|
+
*/
|
|
62
|
+
function extractParsedUserContextFromGrowthBookUserContext(userContext) {
|
|
63
|
+
return {
|
|
64
|
+
did: userContext.attributes?.did,
|
|
65
|
+
deviceId: userContext.attributes?.deviceId,
|
|
66
|
+
sessionId: userContext.attributes?.sessionId,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Convert the `ParsedUserContext` into the `TrackingMetadata` format that we
|
|
71
|
+
* use for our analytics events. This ensures that we have the same user
|
|
72
|
+
* properties as we do for events from our client app.
|
|
73
|
+
*/
|
|
74
|
+
function parsedUserContextToTrackingMetadata(parsedUserContext) {
|
|
75
|
+
return {
|
|
76
|
+
base: {
|
|
77
|
+
deviceId: parsedUserContext.deviceId,
|
|
78
|
+
sessionId: parsedUserContext.sessionId,
|
|
79
|
+
},
|
|
80
|
+
session: {
|
|
81
|
+
did: parsedUserContext.did ?? undefined,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=utils.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../../src/feature-gates/utils.ts"],"names":[],"mappings":";;;;;AAgBA,kDAwCC;AAMD,8GAQC;AAOD,kFAYC;AAzFD,8DAAgC;AAIhC;;GAEG;AACH,MAAM,0BAA0B,GAAG,kBAAkB,CAAA;AACrD,MAAM,2BAA2B,GAAG,mBAAmB,CAAA;AAEvD;;;;;GAKG;AACH,SAAgB,mBAAmB,CACjC,WAA2B;IAE3B,MAAM,GAAG,GAAG,WAAW,CAAC,MAAM,CAAA;IAE9B,gCAAgC;IAChC,IAAI,QAAQ,GAAG,WAAW,CAAC,GAAG,CAAC,MAAM,CAAC,0BAA0B,CAAC,CAAA;IACjE,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,IAAI,GAAG,EAAE,CAAC;YACR;;;;;eAKG;YACH,QAAQ,GAAG,GAAG,CAAA;QAChB,CAAC;aAAM,CAAC;YACN;;;eAGG;YACH,QAAQ,GAAG,QAAQ,qBAAM,CAAC,UAAU,EAAE,EAAE,CAAA;QAC1C,CAAC;IACH,CAAC;IAED,gCAAgC;IAChC,IAAI,SAAS,GAAG,WAAW,CAAC,GAAG,CAAC,MAAM,CAAC,2BAA2B,CAAC,CAAA;IACnE,IAAI,CAAC,SAAS,EAAE,CAAC;QACf;;;WAGG;QACH,SAAS,GAAG,QAAQ,qBAAM,CAAC,UAAU,EAAE,EAAE,CAAA;IAC3C,CAAC;IAED,OAAO;QACL,GAAG;QACH,QAAQ;QACR,SAAS;KACV,CAAA;AACH,CAAC;AAED;;;GAGG;AACH,SAAgB,iDAAiD,CAC/D,WAAkC;IAElC,OAAO;QACL,GAAG,EAAE,WAAW,CAAC,UAAU,EAAE,GAAG;QAChC,QAAQ,EAAE,WAAW,CAAC,UAAU,EAAE,QAAQ;QAC1C,SAAS,EAAE,WAAW,CAAC,UAAU,EAAE,SAAS;KAC7C,CAAA;AACH,CAAC;AAED;;;;GAIG;AACH,SAAgB,mCAAmC,CACjD,iBAAoC;IAEpC,OAAO;QACL,IAAI,EAAE;YACJ,QAAQ,EAAE,iBAAiB,CAAC,QAAQ;YACpC,SAAS,EAAE,iBAAiB,CAAC,SAAS;SACvC;QACD,OAAO,EAAE;YACP,GAAG,EAAE,iBAAiB,CAAC,GAAG,IAAI,SAAS;SACxC;KACF,CAAA;AACH,CAAC","sourcesContent":["import crypto from 'node:crypto'\nimport { type UserContext as GrowthBookUserContext } from '@growthbook/growthbook'\nimport { ParsedUserContext, RawUserContext, TrackingMetadata } from './types'\n\n/**\n * These need to match what the client sends\n */\nconst ANALYTICS_HEADER_DEVICE_ID = 'X-Bsky-Device-Id'\nconst ANALYTICS_HEADER_SESSION_ID = 'X-Bsky-Session-Id'\n\n/**\n * Parse the `RawUserContext` into a `ParsedUserContext` that is used as\n * GrowthBook `attributes` as well as the metadata payload for our analytics\n * events. This ensures that the same user properties are used for both feature\n * gate targeting and analytics.\n */\nexport function parseRawUserContext(\n userContext: RawUserContext,\n): ParsedUserContext {\n const did = userContext.viewer\n\n // prioritize passthrough header\n let deviceId = userContext.req.header(ANALYTICS_HEADER_DEVICE_ID)\n if (!deviceId) {\n if (did) {\n /*\n * If we don't have a device header, fall back to the DID. Our event\n * proxy ensures ordering based on this deviceId (also called a stableId\n * in the proxy), so if we have a DID, we want to use it to ensure client\n * and server events are properly ordered.\n */\n deviceId = did\n } else {\n /*\n * Without any better option for identifying the user, we generate a\n * random deviceId.\n */\n deviceId = `anon-${crypto.randomUUID()}`\n }\n }\n\n // prioritize passthrough header\n let sessionId = userContext.req.header(ANALYTICS_HEADER_SESSION_ID)\n if (!sessionId) {\n /*\n * Without any better option for identifying the user, we generate a\n * random deviceId.\n */\n sessionId = `anon-${crypto.randomUUID()}`\n }\n\n return {\n did,\n deviceId,\n sessionId,\n }\n}\n\n/**\n * Extract the `ParsedUserContext` from the GrowthBook `UserContext`, which we\n * passed into `isOn` as `attributes`.\n */\nexport function extractParsedUserContextFromGrowthBookUserContext(\n userContext: GrowthBookUserContext,\n): ParsedUserContext {\n return {\n did: userContext.attributes?.did,\n deviceId: userContext.attributes?.deviceId,\n sessionId: userContext.attributes?.sessionId,\n }\n}\n\n/**\n * Convert the `ParsedUserContext` into the `TrackingMetadata` format that we\n * use for our analytics events. This ensures that we have the same user\n * properties as we do for events from our client app.\n */\nexport function parsedUserContextToTrackingMetadata(\n parsedUserContext: ParsedUserContext,\n): TrackingMetadata {\n return {\n base: {\n deviceId: parsedUserContext.deviceId,\n sessionId: parsedUserContext.sessionId,\n },\n session: {\n did: parsedUserContext.did ?? undefined,\n },\n }\n}\n"]}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DataPlaneClient } from '../data-plane/client';
|
|
2
|
-
import { type CheckedFeatureGatesMap } from '../feature-gates';
|
|
2
|
+
import { type CheckedFeatureGatesMap } from '../feature-gates/types';
|
|
3
3
|
import { Record as ProfileRecord } from '../lexicon/types/app/bsky/actor/profile';
|
|
4
4
|
import { Bookmark, BookmarkInfo, Notification } from '../proto/bsky_pb';
|
|
5
5
|
import { ParsedLabelers } from '../util';
|
|
@@ -16,7 +16,12 @@ export declare class HydrateCtx {
|
|
|
16
16
|
overrideIncludeTakedownsForActor: boolean | undefined;
|
|
17
17
|
include3pBlocks: boolean | undefined;
|
|
18
18
|
includeDebugField: boolean | undefined;
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Cache of evaluated feature gates to be used in a given request lifecycle.
|
|
21
|
+
* The actual evaluations happen at the top of the route handler and the
|
|
22
|
+
* results are stored in this map.
|
|
23
|
+
*/
|
|
24
|
+
featureGatesMap: CheckedFeatureGatesMap;
|
|
20
25
|
constructor(vals: HydrateCtxVals);
|
|
21
26
|
get skipCacheForViewer(): string[] | undefined;
|
|
22
27
|
copy<V extends Partial<HydrateCtxVals>>(vals?: V): HydrateCtx & V;
|
|
@@ -28,7 +33,7 @@ export type HydrateCtxVals = {
|
|
|
28
33
|
overrideIncludeTakedownsForActor?: boolean;
|
|
29
34
|
include3pBlocks?: boolean;
|
|
30
35
|
includeDebugField?: boolean;
|
|
31
|
-
|
|
36
|
+
featureGatesMap?: CheckedFeatureGatesMap;
|
|
32
37
|
};
|
|
33
38
|
export type HydrationState = {
|
|
34
39
|
ctx?: HydrateCtx;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hydrator.d.ts","sourceRoot":"","sources":["../../src/hydration/hydrator.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,KAAK,sBAAsB,
|
|
1
|
+
{"version":3,"file":"hydrator.d.ts","sourceRoot":"","sources":["../../src/hydration/hydrator.ts"],"names":[],"mappings":"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"}
|
|
@@ -7,7 +7,6 @@ exports.mergeManyStates = exports.mergeStates = exports.Hydrator = exports.Hydra
|
|
|
7
7
|
const node_assert_1 = __importDefault(require("node:assert"));
|
|
8
8
|
const common_1 = require("@atproto/common");
|
|
9
9
|
const syntax_1 = require("@atproto/syntax");
|
|
10
|
-
const feature_gates_1 = require("../feature-gates");
|
|
11
10
|
const lexicons_1 = require("../lexicon/lexicons");
|
|
12
11
|
const record_1 = require("../lexicon/types/app/bsky/embed/record");
|
|
13
12
|
const recordWithMedia_1 = require("../lexicon/types/app/bsky/embed/recordWithMedia");
|
|
@@ -63,11 +62,16 @@ class HydrateCtx {
|
|
|
63
62
|
writable: true,
|
|
64
63
|
value: this.vals.includeDebugField
|
|
65
64
|
});
|
|
66
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Cache of evaluated feature gates to be used in a given request lifecycle.
|
|
67
|
+
* The actual evaluations happen at the top of the route handler and the
|
|
68
|
+
* results are stored in this map.
|
|
69
|
+
*/
|
|
70
|
+
Object.defineProperty(this, "featureGatesMap", {
|
|
67
71
|
enumerable: true,
|
|
68
72
|
configurable: true,
|
|
69
73
|
writable: true,
|
|
70
|
-
value: this.vals.
|
|
74
|
+
value: this.vals.featureGatesMap || new Map()
|
|
71
75
|
});
|
|
72
76
|
}
|
|
73
77
|
// Convenience with use with dataplane.getActors cache control
|
|
@@ -553,7 +557,7 @@ class Hydrator {
|
|
|
553
557
|
// - list basic
|
|
554
558
|
async hydrateThreadPosts(refs, ctx) {
|
|
555
559
|
const postsState = await this.hydratePosts(refs, ctx, undefined, {
|
|
556
|
-
processDynamicTagsForView: ctx.
|
|
560
|
+
processDynamicTagsForView: ctx.featureGatesMap.get('threads:reply_ranking_exploration:enable')
|
|
557
561
|
? 'thread'
|
|
558
562
|
: undefined,
|
|
559
563
|
});
|
|
@@ -1002,7 +1006,7 @@ class Hydrator {
|
|
|
1002
1006
|
includeTakedowns: vals.includeTakedowns,
|
|
1003
1007
|
include3pBlocks: vals.include3pBlocks,
|
|
1004
1008
|
includeDebugField,
|
|
1005
|
-
|
|
1009
|
+
featureGatesMap: vals.featureGatesMap,
|
|
1006
1010
|
});
|
|
1007
1011
|
}
|
|
1008
1012
|
async resolveUri(uriStr) {
|