@sanity/sdk 2.8.0 → 2.9.0
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/dist/_chunks-dts/utils.d.ts +2396 -0
- package/dist/_chunks-es/_internal.js +129 -0
- package/dist/_chunks-es/_internal.js.map +1 -0
- package/dist/_chunks-es/createGroqSearchFilter.js +1460 -0
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -0
- package/dist/_chunks-es/telemetryManager.js +87 -0
- package/dist/_chunks-es/telemetryManager.js.map +1 -0
- package/dist/_chunks-es/version.js +7 -0
- package/dist/_chunks-es/version.js.map +1 -0
- package/dist/_exports/_internal.d.ts +64 -0
- package/dist/_exports/_internal.js +20 -0
- package/dist/_exports/_internal.js.map +1 -0
- package/dist/index.d.ts +2 -2343
- package/dist/index.js +383 -1777
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/src/_exports/_internal.ts +14 -0
- package/src/_exports/index.ts +10 -1
- package/src/auth/authStore.test.ts +150 -1
- package/src/auth/authStore.ts +11 -11
- package/src/auth/dashboardAuth.ts +2 -2
- package/src/auth/handleAuthCallback.ts +9 -3
- package/src/auth/logout.test.ts +1 -1
- package/src/auth/logout.ts +1 -1
- package/src/auth/refreshStampedToken.test.ts +118 -1
- package/src/auth/refreshStampedToken.ts +3 -2
- package/src/auth/standaloneAuth.ts +9 -3
- package/src/auth/studioAuth.ts +34 -7
- package/src/auth/studioModeAuth.ts +2 -1
- package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +10 -2
- package/src/auth/subscribeToStateAndFetchCurrentUser.ts +5 -1
- package/src/auth/subscribeToStorageEventsAndSetToken.ts +2 -2
- package/src/auth/utils.ts +33 -0
- package/src/client/clientStore.test.ts +14 -0
- package/src/client/clientStore.ts +2 -1
- package/src/comlink/node/getNodeState.ts +2 -1
- package/src/config/sanityConfig.ts +6 -0
- package/src/document/actions.ts +18 -11
- package/src/document/applyDocumentActions.test.ts +7 -6
- package/src/document/applyDocumentActions.ts +10 -4
- package/src/document/documentStore.test.ts +536 -188
- package/src/document/documentStore.ts +142 -76
- package/src/document/events.ts +7 -2
- package/src/document/permissions.test.ts +18 -16
- package/src/document/permissions.ts +35 -11
- package/src/document/processActions.test.ts +359 -32
- package/src/document/processActions.ts +104 -76
- package/src/document/reducers.test.ts +117 -29
- package/src/document/reducers.ts +43 -36
- package/src/document/sharedListener.ts +16 -6
- package/src/document/util.ts +14 -0
- package/src/favorites/favorites.test.ts +9 -2
- package/src/presence/bifurTransport.ts +6 -1
- package/src/preview/getPreviewState.test.ts +115 -98
- package/src/preview/getPreviewState.ts +38 -60
- package/src/preview/previewProjectionUtils.test.ts +179 -0
- package/src/preview/previewProjectionUtils.ts +93 -0
- package/src/preview/resolvePreview.test.ts +42 -25
- package/src/preview/resolvePreview.ts +29 -10
- package/src/preview/{previewStore.ts → types.ts} +8 -17
- package/src/projection/getProjectionState.test.ts +16 -16
- package/src/projection/getProjectionState.ts +2 -1
- package/src/projection/projectionQuery.ts +2 -3
- package/src/projection/types.ts +1 -1
- package/src/query/queryStore.ts +2 -1
- package/src/releases/getPerspectiveState.ts +7 -6
- package/src/releases/releasesStore.test.ts +20 -5
- package/src/releases/releasesStore.ts +20 -8
- package/src/store/createStateSourceAction.test.ts +62 -0
- package/src/store/createStateSourceAction.ts +34 -39
- package/src/telemetry/__telemetry__/sdk.telemetry.ts +42 -0
- package/src/telemetry/devMode.test.ts +52 -0
- package/src/telemetry/devMode.ts +40 -0
- package/src/telemetry/initTelemetry.test.ts +225 -0
- package/src/telemetry/initTelemetry.ts +205 -0
- package/src/telemetry/telemetryManager.test.ts +263 -0
- package/src/telemetry/telemetryManager.ts +187 -0
- package/src/users/usersStore.test.ts +1 -0
- package/src/users/usersStore.ts +5 -1
- package/src/utils/createFetcherStore.test.ts +6 -4
- package/src/utils/createFetcherStore.ts +2 -1
- package/src/utils/getStagingApiHost.test.ts +21 -0
- package/src/utils/getStagingApiHost.ts +14 -0
- package/src/utils/ids.test.ts +1 -29
- package/src/utils/ids.ts +0 -10
- package/src/utils/setCleanupTimeout.ts +24 -0
- package/src/preview/previewQuery.test.ts +0 -236
- package/src/preview/previewQuery.ts +0 -153
- package/src/preview/previewStore.test.ts +0 -36
- package/src/preview/subscribeToStateAndFetchBatches.test.ts +0 -221
- package/src/preview/subscribeToStateAndFetchBatches.ts +0 -112
- package/src/preview/util.ts +0 -13
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {type SanityInstance} from '../store/createSanityInstance'
|
|
2
|
+
import {createLogger} from '../utils/logger'
|
|
3
|
+
import {isDevMode} from './devMode'
|
|
4
|
+
import {type TelemetryManager} from './telemetryManager'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TELEMETRY_API_VERSION = '2024-11-12'
|
|
7
|
+
|
|
8
|
+
const logger = createLogger('telemetry')
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Per-instance map of active telemetry managers. Allows the React
|
|
12
|
+
* package (and other consumers) to look up the manager for a given
|
|
13
|
+
* instance and log hook usage / errors without importing the full
|
|
14
|
+
* telemetry module themselves.
|
|
15
|
+
*
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
const telemetryManagers = new WeakMap<SanityInstance, TelemetryManager>()
|
|
19
|
+
const pendingHooks = new WeakMap<SanityInstance, Set<string>>()
|
|
20
|
+
const initInFlight = new WeakSet<SanityInstance>()
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Initializes dev-mode telemetry for a SDK instance if the environment
|
|
24
|
+
* qualifies. Both `telemetryManager` and `clientStore` are dynamically
|
|
25
|
+
* imported to avoid circular dependencies and to keep telemetry code
|
|
26
|
+
* out of production bundles via code splitting.
|
|
27
|
+
*
|
|
28
|
+
* The `projectId` must be passed explicitly because the resource
|
|
29
|
+
* configuration is typically set by the React layer after the
|
|
30
|
+
* instance has already been created.
|
|
31
|
+
*
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
export function initTelemetry(instance: SanityInstance, projectId: string): void {
|
|
35
|
+
if (!isDevMode()) {
|
|
36
|
+
logger.trace('initTelemetry skipped: not dev mode', {internal: true})
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if (!projectId) {
|
|
40
|
+
logger.trace('initTelemetry skipped: no projectId', {internal: true})
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
if (telemetryManagers.has(instance) || initInFlight.has(instance)) {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
initInFlight.add(instance)
|
|
47
|
+
|
|
48
|
+
logger.debug('initializing telemetry', {projectId})
|
|
49
|
+
|
|
50
|
+
Promise.all([
|
|
51
|
+
import('./telemetryManager'),
|
|
52
|
+
import('../client/clientStore'),
|
|
53
|
+
import('../auth/authStore'),
|
|
54
|
+
])
|
|
55
|
+
.then(async ([{createTelemetryManager}, {getClient}, {getTokenState}]) => {
|
|
56
|
+
if (instance.isDisposed()) {
|
|
57
|
+
initInFlight.delete(instance)
|
|
58
|
+
logger.debug('telemetry skipped: instance disposed before imports resolved')
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Wait for the auth store to resolve a token. The client needs a
|
|
63
|
+
// Bearer token for the consent check and batch POSTs. If the token
|
|
64
|
+
// is already available (e.g. static token config), this resolves
|
|
65
|
+
// immediately. For OAuth/localStorage discovery it waits for the
|
|
66
|
+
// first emission. For unauthenticated apps the promise never
|
|
67
|
+
// resolves, which is fine since telemetry requires auth anyway.
|
|
68
|
+
const token = getTokenState(instance).getCurrent()
|
|
69
|
+
logger.trace('auth token check', {tokenPresent: !!token, internal: true})
|
|
70
|
+
|
|
71
|
+
if (!token) {
|
|
72
|
+
logger.debug('waiting for auth token')
|
|
73
|
+
const hasToken = await new Promise<boolean>((resolve) => {
|
|
74
|
+
if (instance.isDisposed()) return resolve(false)
|
|
75
|
+
const cleanup = {unsubscribe: () => {}}
|
|
76
|
+
const unsub = instance.onDispose(() => {
|
|
77
|
+
cleanup.unsubscribe()
|
|
78
|
+
resolve(false)
|
|
79
|
+
})
|
|
80
|
+
const sub = getTokenState(instance).observable.subscribe((t) => {
|
|
81
|
+
if (t) {
|
|
82
|
+
logger.debug('auth token received')
|
|
83
|
+
sub.unsubscribe()
|
|
84
|
+
unsub()
|
|
85
|
+
resolve(true)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
cleanup.unsubscribe = () => sub.unsubscribe()
|
|
89
|
+
})
|
|
90
|
+
if (!hasToken || instance.isDisposed()) {
|
|
91
|
+
initInFlight.delete(instance)
|
|
92
|
+
logger.debug('telemetry skipped: no token resolved or instance disposed')
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const manager = createTelemetryManager({
|
|
98
|
+
sessionId: instance.instanceId,
|
|
99
|
+
getClient: () => getClient(instance, {apiVersion: DEFAULT_TELEMETRY_API_VERSION}),
|
|
100
|
+
projectId,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const consented = await manager.checkConsent()
|
|
104
|
+
logger.debug('consent check complete', {consented})
|
|
105
|
+
if (!consented || instance.isDisposed()) {
|
|
106
|
+
initInFlight.delete(instance)
|
|
107
|
+
manager.dispose()
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
initInFlight.delete(instance)
|
|
112
|
+
telemetryManagers.set(instance, manager)
|
|
113
|
+
|
|
114
|
+
const buffered = pendingHooks.get(instance)
|
|
115
|
+
if (buffered) {
|
|
116
|
+
logger.debug('flushing buffered hooks', {hooks: Array.from(buffered)})
|
|
117
|
+
for (const hookName of buffered) {
|
|
118
|
+
manager.logHookFirstUsed(hookName)
|
|
119
|
+
}
|
|
120
|
+
pendingHooks.delete(instance)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const config = instance.config
|
|
124
|
+
const perspective = typeof config.perspective === 'string' ? config.perspective : 'published'
|
|
125
|
+
const authMethod = config.auth?.token
|
|
126
|
+
? 'token'
|
|
127
|
+
: config.studio?.auth?.token
|
|
128
|
+
? 'studio'
|
|
129
|
+
: 'default'
|
|
130
|
+
|
|
131
|
+
logger.info('telemetry session started', {projectId, perspective, authMethod})
|
|
132
|
+
manager.logSessionStarted({
|
|
133
|
+
projectId,
|
|
134
|
+
perspective,
|
|
135
|
+
authMethod,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
instance.onDispose(() => {
|
|
139
|
+
manager.endSession()
|
|
140
|
+
telemetryManagers.delete(instance)
|
|
141
|
+
logger.debug('telemetry session ended')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
.catch((err) => {
|
|
145
|
+
initInFlight.delete(instance)
|
|
146
|
+
logger.warn('telemetry init failed', {error: err})
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Retrieves the telemetry manager for an instance, if one exists.
|
|
152
|
+
* Returns undefined when telemetry is disabled or not yet initialized.
|
|
153
|
+
*
|
|
154
|
+
* @internal
|
|
155
|
+
*/
|
|
156
|
+
export function getTelemetryManager(instance: SanityInstance): TelemetryManager | undefined {
|
|
157
|
+
return telemetryManagers.get(instance)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Record a hook name for an instance. If the telemetry manager is
|
|
162
|
+
* already initialized the event is logged immediately. Otherwise
|
|
163
|
+
* the name is buffered and flushed when init completes.
|
|
164
|
+
*
|
|
165
|
+
* @internal
|
|
166
|
+
*/
|
|
167
|
+
export function trackHookMounted(instance: SanityInstance, hookName: string): void {
|
|
168
|
+
if (!isDevMode()) return
|
|
169
|
+
|
|
170
|
+
const manager = findManager(instance)
|
|
171
|
+
if (manager) {
|
|
172
|
+
logger.trace('hook mounted (logged)', {hookName, internal: true})
|
|
173
|
+
manager.logHookFirstUsed(hookName)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const root = getRootInstance(instance)
|
|
178
|
+
let hooks = pendingHooks.get(root)
|
|
179
|
+
if (!hooks) {
|
|
180
|
+
hooks = new Set()
|
|
181
|
+
pendingHooks.set(root, hooks)
|
|
182
|
+
}
|
|
183
|
+
if (!hooks.has(hookName)) {
|
|
184
|
+
logger.trace('hook mounted (buffered)', {hookName, internal: true})
|
|
185
|
+
}
|
|
186
|
+
hooks.add(hookName)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function findManager(instance: SanityInstance): TelemetryManager | undefined {
|
|
190
|
+
let current: SanityInstance | undefined = instance
|
|
191
|
+
while (current) {
|
|
192
|
+
const manager = telemetryManagers.get(current)
|
|
193
|
+
if (manager) return manager
|
|
194
|
+
current = typeof current.getParent === 'function' ? current.getParent() : undefined
|
|
195
|
+
}
|
|
196
|
+
return undefined
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getRootInstance(instance: SanityInstance): SanityInstance {
|
|
200
|
+
let current = instance
|
|
201
|
+
while (typeof current.getParent === 'function' && current.getParent()) {
|
|
202
|
+
current = current.getParent()!
|
|
203
|
+
}
|
|
204
|
+
return current
|
|
205
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import {createBatchedStore} from '@sanity/telemetry'
|
|
2
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {createTelemetryManager} from './telemetryManager'
|
|
5
|
+
|
|
6
|
+
vi.mock('@sanity/telemetry', () => {
|
|
7
|
+
const mockLogger = {
|
|
8
|
+
log: vi.fn(),
|
|
9
|
+
updateUserProperties: vi.fn(),
|
|
10
|
+
resume: vi.fn(),
|
|
11
|
+
trace: vi.fn(),
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
createBatchedStore: vi.fn(() => ({
|
|
15
|
+
logger: mockLogger,
|
|
16
|
+
end: vi.fn(),
|
|
17
|
+
flush: vi.fn(() => Promise.resolve()),
|
|
18
|
+
})),
|
|
19
|
+
defineEvent: vi.fn((opts) => ({
|
|
20
|
+
type: 'log' as const,
|
|
21
|
+
name: opts.name,
|
|
22
|
+
version: opts.version,
|
|
23
|
+
description: opts.description,
|
|
24
|
+
schema: undefined as never,
|
|
25
|
+
})),
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
vi.mock('../version', () => ({
|
|
30
|
+
CORE_SDK_VERSION: '2.8.0-test',
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
describe('createTelemetryManager', () => {
|
|
34
|
+
const mockClient = {
|
|
35
|
+
request: vi.fn((): Promise<unknown> => Promise.resolve()),
|
|
36
|
+
getUrl: vi.fn((path: string) => `https://abc123.api.sanity.io/v2024-11-12${path}`),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const getClient = () => mockClient as never
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.clearAllMocks()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.restoreAllMocks()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('creates a batched store with the given session ID', () => {
|
|
50
|
+
createTelemetryManager({
|
|
51
|
+
sessionId: 'test-session-id',
|
|
52
|
+
getClient,
|
|
53
|
+
projectId: 'abc123',
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
expect(createBatchedStore).toHaveBeenCalledWith(
|
|
57
|
+
'test-session-id',
|
|
58
|
+
expect.objectContaining({
|
|
59
|
+
flushInterval: 30_000,
|
|
60
|
+
}),
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('logs session started with SDK version and provided data', () => {
|
|
65
|
+
const manager = createTelemetryManager({
|
|
66
|
+
sessionId: 'test-session-id',
|
|
67
|
+
getClient,
|
|
68
|
+
projectId: 'abc123',
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const storeInstance = vi.mocked(createBatchedStore).mock.results[0].value
|
|
72
|
+
const logger = storeInstance.logger
|
|
73
|
+
|
|
74
|
+
manager.logSessionStarted({
|
|
75
|
+
projectId: 'abc123',
|
|
76
|
+
perspective: 'published',
|
|
77
|
+
authMethod: 'token',
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
expect(logger.log).toHaveBeenCalledWith(
|
|
81
|
+
expect.objectContaining({name: 'SDK Dev Session Started'}),
|
|
82
|
+
expect.objectContaining({
|
|
83
|
+
version: '2.8.0-test',
|
|
84
|
+
projectId: 'abc123',
|
|
85
|
+
perspective: 'published',
|
|
86
|
+
authMethod: 'token',
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('deduplicates hook first-used events by name', () => {
|
|
92
|
+
const manager = createTelemetryManager({
|
|
93
|
+
sessionId: 'test-session-id',
|
|
94
|
+
getClient,
|
|
95
|
+
projectId: 'abc123',
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const storeInstance = vi.mocked(createBatchedStore).mock.results[0].value
|
|
99
|
+
const logger = storeInstance.logger
|
|
100
|
+
|
|
101
|
+
manager.logHookFirstUsed('useQuery')
|
|
102
|
+
manager.logHookFirstUsed('useQuery')
|
|
103
|
+
manager.logHookFirstUsed('useDocument')
|
|
104
|
+
|
|
105
|
+
const hookCalls = vi
|
|
106
|
+
.mocked(logger.log)
|
|
107
|
+
.mock.calls.filter(([event]: [{name: string}]) => event.name === 'SDK Hook Mounted')
|
|
108
|
+
expect(hookCalls).toHaveLength(2)
|
|
109
|
+
expect(hookCalls[0][1]).toEqual({hookName: 'useQuery'})
|
|
110
|
+
expect(hookCalls[1][1]).toEqual({hookName: 'useDocument'})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('tracks hooksUsed set', () => {
|
|
114
|
+
const manager = createTelemetryManager({
|
|
115
|
+
sessionId: 'test-session-id',
|
|
116
|
+
getClient,
|
|
117
|
+
projectId: 'abc123',
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
manager.logHookFirstUsed('useQuery')
|
|
121
|
+
manager.logHookFirstUsed('useDocument')
|
|
122
|
+
|
|
123
|
+
expect(manager.hooksUsed).toEqual(new Set(['useQuery', 'useDocument']))
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('logs dev error events', () => {
|
|
127
|
+
const manager = createTelemetryManager({
|
|
128
|
+
sessionId: 'test-session-id',
|
|
129
|
+
getClient,
|
|
130
|
+
projectId: 'abc123',
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const storeInstance = vi.mocked(createBatchedStore).mock.results[0].value
|
|
134
|
+
const logger = storeInstance.logger
|
|
135
|
+
|
|
136
|
+
manager.logDevError('TypeError', 'documentStore')
|
|
137
|
+
|
|
138
|
+
expect(logger.log).toHaveBeenCalledWith(expect.objectContaining({name: 'SDK Dev Error'}), {
|
|
139
|
+
errorType: 'TypeError',
|
|
140
|
+
hookName: 'documentStore',
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('logs session ended with duration and hooksUsed on endSession', () => {
|
|
145
|
+
vi.useFakeTimers()
|
|
146
|
+
|
|
147
|
+
const manager = createTelemetryManager({
|
|
148
|
+
sessionId: 'test-session-id',
|
|
149
|
+
getClient,
|
|
150
|
+
projectId: 'abc123',
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const storeInstance = vi.mocked(createBatchedStore).mock.results[0].value
|
|
154
|
+
const logger = storeInstance.logger
|
|
155
|
+
|
|
156
|
+
manager.logHookFirstUsed('useQuery')
|
|
157
|
+
|
|
158
|
+
vi.advanceTimersByTime(5000)
|
|
159
|
+
|
|
160
|
+
manager.endSession()
|
|
161
|
+
|
|
162
|
+
expect(logger.log).toHaveBeenCalledWith(
|
|
163
|
+
expect.objectContaining({name: 'SDK Dev Session Ended'}),
|
|
164
|
+
expect.objectContaining({
|
|
165
|
+
durationSeconds: 5,
|
|
166
|
+
hooksUsed: ['useQuery'],
|
|
167
|
+
}),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
vi.useRealTimers()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('endSession teardown', () => {
|
|
174
|
+
it('always uses flush + end (no sendBeacon due to auth header limitation)', () => {
|
|
175
|
+
const manager = createTelemetryManager({
|
|
176
|
+
sessionId: 'test-session-id',
|
|
177
|
+
getClient,
|
|
178
|
+
projectId: 'abc123',
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const storeInstance = vi.mocked(createBatchedStore).mock.results[0].value
|
|
182
|
+
|
|
183
|
+
manager.endSession()
|
|
184
|
+
|
|
185
|
+
expect(storeInstance.flush).toHaveBeenCalled()
|
|
186
|
+
expect(storeInstance.end).toHaveBeenCalled()
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('consent resolution', () => {
|
|
191
|
+
it('checkConsent returns true when user has opted in', async () => {
|
|
192
|
+
mockClient.request.mockResolvedValue({status: 'granted'})
|
|
193
|
+
|
|
194
|
+
const manager = createTelemetryManager({
|
|
195
|
+
sessionId: 'test-session-id',
|
|
196
|
+
getClient,
|
|
197
|
+
projectId: 'abc123',
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
const result = await manager.checkConsent()
|
|
201
|
+
expect(result).toBe(true)
|
|
202
|
+
expect(mockClient.request).toHaveBeenCalledWith(
|
|
203
|
+
expect.objectContaining({uri: '/intake/telemetry-status'}),
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('checkConsent returns false when user has denied telemetry', async () => {
|
|
208
|
+
mockClient.request.mockResolvedValue({status: 'denied'})
|
|
209
|
+
|
|
210
|
+
const manager = createTelemetryManager({
|
|
211
|
+
sessionId: 'test-session-id',
|
|
212
|
+
getClient,
|
|
213
|
+
projectId: 'abc123',
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
expect(await manager.checkConsent()).toBe(false)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('checkConsent returns false when consent is unset', async () => {
|
|
220
|
+
mockClient.request.mockResolvedValue({status: 'unset'})
|
|
221
|
+
|
|
222
|
+
const manager = createTelemetryManager({
|
|
223
|
+
sessionId: 'test-session-id',
|
|
224
|
+
getClient,
|
|
225
|
+
projectId: 'abc123',
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
expect(await manager.checkConsent()).toBe(false)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('checkConsent returns false on network failure', async () => {
|
|
232
|
+
mockClient.request.mockRejectedValue(new Error('Network error'))
|
|
233
|
+
|
|
234
|
+
const manager = createTelemetryManager({
|
|
235
|
+
sessionId: 'test-session-id',
|
|
236
|
+
getClient,
|
|
237
|
+
projectId: 'abc123',
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
expect(await manager.checkConsent()).toBe(false)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('caches consent after the first call', async () => {
|
|
244
|
+
mockClient.request.mockResolvedValue({status: 'granted'})
|
|
245
|
+
|
|
246
|
+
createTelemetryManager({
|
|
247
|
+
sessionId: 'test-session-id',
|
|
248
|
+
getClient,
|
|
249
|
+
projectId: 'abc123',
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
const storeOptions = vi.mocked(createBatchedStore).mock.calls[0][1]
|
|
253
|
+
const resolveConsent = storeOptions.resolveConsent
|
|
254
|
+
|
|
255
|
+
const first = await resolveConsent()
|
|
256
|
+
const second = await resolveConsent()
|
|
257
|
+
|
|
258
|
+
expect(first).toEqual({status: 'granted'})
|
|
259
|
+
expect(second).toEqual({status: 'granted'})
|
|
260
|
+
expect(mockClient.request).toHaveBeenCalledTimes(1)
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {type SanityClient} from '@sanity/client'
|
|
2
|
+
import {
|
|
3
|
+
type ConsentStatus,
|
|
4
|
+
createBatchedStore,
|
|
5
|
+
type SessionId,
|
|
6
|
+
type TelemetryEvent,
|
|
7
|
+
type TelemetryLogger,
|
|
8
|
+
type TelemetryStore,
|
|
9
|
+
} from '@sanity/telemetry'
|
|
10
|
+
|
|
11
|
+
import {createLogger} from '../utils/logger'
|
|
12
|
+
import {CORE_SDK_VERSION} from '../version'
|
|
13
|
+
import {
|
|
14
|
+
SDKDevError,
|
|
15
|
+
SDKDevSessionEnded,
|
|
16
|
+
SDKDevSessionStarted,
|
|
17
|
+
SDKHookMounted,
|
|
18
|
+
} from './__telemetry__/sdk.telemetry'
|
|
19
|
+
|
|
20
|
+
const FLUSH_INTERVAL_MS = 30_000
|
|
21
|
+
const CONSENT_TAG = 'telemetry-consent.sdk'
|
|
22
|
+
const BATCH_TAG = 'telemetry.batch'
|
|
23
|
+
|
|
24
|
+
const log = createLogger('telemetry')
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Manages dev-mode telemetry for a single SDK instance.
|
|
28
|
+
*
|
|
29
|
+
* Wraps `@sanity/telemetry`'s batched store with SDK-specific concerns:
|
|
30
|
+
* consent caching, session lifecycle events, and hook usage tracking.
|
|
31
|
+
*
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
export interface TelemetryManager {
|
|
35
|
+
/**
|
|
36
|
+
* Eagerly resolve and cache the user's consent status.
|
|
37
|
+
* Returns true only when the user has explicitly opted in (`granted`).
|
|
38
|
+
* Call this before logging any events to avoid buffering events that
|
|
39
|
+
* will be dropped on the first flush.
|
|
40
|
+
*/
|
|
41
|
+
checkConsent(): Promise<boolean>
|
|
42
|
+
|
|
43
|
+
/** Log a "SDK Dev Session Started" event */
|
|
44
|
+
logSessionStarted(data: {projectId: string; perspective: string; authMethod: string}): void
|
|
45
|
+
|
|
46
|
+
/** Log a "SDK Hook First Used" event (deduplicated per hook name) */
|
|
47
|
+
logHookFirstUsed(hookName: string): void
|
|
48
|
+
|
|
49
|
+
/** Log a "SDK Dev Error" event */
|
|
50
|
+
logDevError(errorType: string, hookName: string): void
|
|
51
|
+
|
|
52
|
+
/** Log a "SDK Dev Session Ended" event and tear down the store */
|
|
53
|
+
endSession(): void
|
|
54
|
+
|
|
55
|
+
/** Tear down the store without logging a session-end event */
|
|
56
|
+
dispose(): void
|
|
57
|
+
|
|
58
|
+
/** The set of hook names used during this session */
|
|
59
|
+
readonly hooksUsed: ReadonlySet<string>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface TelemetryManagerOptions {
|
|
63
|
+
sessionId: string
|
|
64
|
+
getClient: () => SanityClient
|
|
65
|
+
projectId: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Creates a telemetry manager for a single SDK instance session.
|
|
70
|
+
*
|
|
71
|
+
* The manager initializes a `createBatchedStore` from `@sanity/telemetry`,
|
|
72
|
+
* caches the consent check for the lifetime of the session, and provides
|
|
73
|
+
* typed methods for each SDK telemetry event.
|
|
74
|
+
*
|
|
75
|
+
* @internal
|
|
76
|
+
*/
|
|
77
|
+
export function createTelemetryManager(options: TelemetryManagerOptions): TelemetryManager {
|
|
78
|
+
const {sessionId, getClient, projectId} = options
|
|
79
|
+
const startedAt = Date.now()
|
|
80
|
+
const emittedHooks = new Set<string>()
|
|
81
|
+
|
|
82
|
+
let cachedConsent: {status: ConsentStatus} | null = null
|
|
83
|
+
|
|
84
|
+
const resolveConsent = async (): Promise<{status: ConsentStatus}> => {
|
|
85
|
+
if (cachedConsent) return cachedConsent
|
|
86
|
+
try {
|
|
87
|
+
const client = getClient()
|
|
88
|
+
const result = await client.request<{status: ConsentStatus}>({
|
|
89
|
+
uri: '/intake/telemetry-status',
|
|
90
|
+
tag: CONSENT_TAG,
|
|
91
|
+
})
|
|
92
|
+
cachedConsent = result
|
|
93
|
+
} catch {
|
|
94
|
+
cachedConsent = {status: 'undetermined'}
|
|
95
|
+
}
|
|
96
|
+
return cachedConsent
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const enrichBatch = (batch: TelemetryEvent[]) =>
|
|
100
|
+
batch.map((event) => ({
|
|
101
|
+
...event,
|
|
102
|
+
context: {
|
|
103
|
+
version: CORE_SDK_VERSION,
|
|
104
|
+
environment: 'development' as const,
|
|
105
|
+
origin: typeof window !== 'undefined' ? window.location.origin : 'node',
|
|
106
|
+
},
|
|
107
|
+
}))
|
|
108
|
+
|
|
109
|
+
const sendEvents = async (batch: TelemetryEvent[]): Promise<unknown> => {
|
|
110
|
+
const client = getClient()
|
|
111
|
+
log.debug('sending event batch', {batchSize: batch.length})
|
|
112
|
+
return client.request({
|
|
113
|
+
uri: '/intake/batch',
|
|
114
|
+
method: 'POST',
|
|
115
|
+
body: {projectId, batch: enrichBatch(batch)},
|
|
116
|
+
tag: BATCH_TAG,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const store: TelemetryStore<Record<string, unknown>> = createBatchedStore(
|
|
121
|
+
sessionId as SessionId,
|
|
122
|
+
{
|
|
123
|
+
flushInterval: FLUSH_INTERVAL_MS,
|
|
124
|
+
resolveConsent,
|
|
125
|
+
sendEvents,
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const logger: TelemetryLogger<Record<string, unknown>> = store.logger
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
async checkConsent() {
|
|
133
|
+
const {status} = await resolveConsent()
|
|
134
|
+
return status === 'granted'
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
logSessionStarted(data) {
|
|
138
|
+
log.debug('event: SDK Dev Session Started', {
|
|
139
|
+
projectId: data.projectId,
|
|
140
|
+
perspective: data.perspective,
|
|
141
|
+
authMethod: data.authMethod,
|
|
142
|
+
version: CORE_SDK_VERSION,
|
|
143
|
+
})
|
|
144
|
+
logger.log(SDKDevSessionStarted, {
|
|
145
|
+
version: CORE_SDK_VERSION,
|
|
146
|
+
...data,
|
|
147
|
+
})
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
logHookFirstUsed(hookName: string) {
|
|
151
|
+
if (emittedHooks.has(hookName)) return
|
|
152
|
+
emittedHooks.add(hookName)
|
|
153
|
+
log.debug('event: SDK Hook Mounted', {hookName})
|
|
154
|
+
logger.log(SDKHookMounted, {hookName})
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
logDevError(errorType: string, hookName: string) {
|
|
158
|
+
log.debug('event: SDK Dev Error', {errorType, hookName})
|
|
159
|
+
logger.log(SDKDevError, {errorType, hookName})
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
endSession() {
|
|
163
|
+
const durationSeconds = Math.round((Date.now() - startedAt) / 1000)
|
|
164
|
+
log.debug('event: SDK Dev Session Ended', {
|
|
165
|
+
durationSeconds,
|
|
166
|
+
hooksUsed: [...emittedHooks],
|
|
167
|
+
})
|
|
168
|
+
logger.log(SDKDevSessionEnded, {
|
|
169
|
+
durationSeconds,
|
|
170
|
+
hooksUsed: [...emittedHooks],
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
store.flush().catch(() => {
|
|
174
|
+
// Best-effort flush on dispose; swallow errors
|
|
175
|
+
})
|
|
176
|
+
store.end()
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
dispose() {
|
|
180
|
+
store.end()
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
get hooksUsed(): ReadonlySet<string> {
|
|
184
|
+
return emittedHooks
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/users/usersStore.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {createStateSourceAction, type SelectorContext} from '../store/createStat
|
|
|
31
31
|
import {type StoreState} from '../store/createStoreState'
|
|
32
32
|
import {defineStore, type StoreContext} from '../store/defineStore'
|
|
33
33
|
import {insecureRandomId} from '../utils/ids'
|
|
34
|
+
import {setCleanupTimeout} from '../utils/setCleanupTimeout'
|
|
34
35
|
import {
|
|
35
36
|
addSubscription,
|
|
36
37
|
cancelRequest,
|
|
@@ -130,6 +131,7 @@ const listenForLoadMoreAndFetch = ({state, instance}: StoreContext<UsersStoreSta
|
|
|
130
131
|
.request<PatchedSanityUserFromClient>({
|
|
131
132
|
method: 'GET',
|
|
132
133
|
uri: `/users/${userId}`,
|
|
134
|
+
tag: 'users.get',
|
|
133
135
|
})
|
|
134
136
|
.pipe(
|
|
135
137
|
map((user) => {
|
|
@@ -183,6 +185,7 @@ const listenForLoadMoreAndFetch = ({state, instance}: StoreContext<UsersStoreSta
|
|
|
183
185
|
.request<SanityUser | SanityUserResponse>({
|
|
184
186
|
method: 'GET',
|
|
185
187
|
uri: `access/${resourceType}/${resourceId}/users/${userId}`,
|
|
188
|
+
tag: 'users.get',
|
|
186
189
|
})
|
|
187
190
|
.pipe(
|
|
188
191
|
map((response) => {
|
|
@@ -251,6 +254,7 @@ const listenForLoadMoreAndFetch = ({state, instance}: StoreContext<UsersStoreSta
|
|
|
251
254
|
client.observable.request<SanityUserResponse>({
|
|
252
255
|
method: 'GET',
|
|
253
256
|
uri: `access/${resource.type}/${resource.id}/users`,
|
|
257
|
+
tag: 'users.list',
|
|
254
258
|
query: cursor
|
|
255
259
|
? {nextCursor: cursor, limit: batchSize.toString()}
|
|
256
260
|
: {limit: batchSize.toString()},
|
|
@@ -310,7 +314,7 @@ export const getUsersState = bindActionGlobally(
|
|
|
310
314
|
const key = getUsersKey(instance, options)
|
|
311
315
|
state.set('addSubscription', addSubscription(subscriptionId, key))
|
|
312
316
|
return () => {
|
|
313
|
-
|
|
317
|
+
setCleanupTimeout(
|
|
314
318
|
() => state.set('removeSubscription', removeSubscription(subscriptionId, key)),
|
|
315
319
|
USERS_STATE_CLEAR_DELAY,
|
|
316
320
|
)
|