@sanity/sdk 2.7.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.
Files changed (92) hide show
  1. package/dist/_chunks-dts/utils.d.ts +2396 -0
  2. package/dist/_chunks-es/_internal.js +129 -0
  3. package/dist/_chunks-es/_internal.js.map +1 -0
  4. package/dist/_chunks-es/createGroqSearchFilter.js +1460 -0
  5. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -0
  6. package/dist/_chunks-es/telemetryManager.js +87 -0
  7. package/dist/_chunks-es/telemetryManager.js.map +1 -0
  8. package/dist/_chunks-es/version.js +7 -0
  9. package/dist/_chunks-es/version.js.map +1 -0
  10. package/dist/_exports/_internal.d.ts +64 -0
  11. package/dist/_exports/_internal.js +20 -0
  12. package/dist/_exports/_internal.js.map +1 -0
  13. package/dist/index.d.ts +2 -2343
  14. package/dist/index.js +383 -1777
  15. package/dist/index.js.map +1 -1
  16. package/package.json +11 -4
  17. package/src/_exports/_internal.ts +14 -0
  18. package/src/_exports/index.ts +10 -1
  19. package/src/auth/authStore.test.ts +150 -1
  20. package/src/auth/authStore.ts +11 -11
  21. package/src/auth/dashboardAuth.ts +2 -2
  22. package/src/auth/handleAuthCallback.ts +9 -3
  23. package/src/auth/logout.test.ts +1 -1
  24. package/src/auth/logout.ts +1 -1
  25. package/src/auth/refreshStampedToken.test.ts +118 -1
  26. package/src/auth/refreshStampedToken.ts +3 -2
  27. package/src/auth/standaloneAuth.ts +9 -3
  28. package/src/auth/studioAuth.ts +34 -7
  29. package/src/auth/studioModeAuth.ts +2 -1
  30. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +10 -2
  31. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +5 -1
  32. package/src/auth/subscribeToStorageEventsAndSetToken.ts +2 -2
  33. package/src/auth/utils.ts +33 -0
  34. package/src/client/clientStore.test.ts +14 -0
  35. package/src/client/clientStore.ts +2 -1
  36. package/src/comlink/node/getNodeState.ts +2 -1
  37. package/src/config/sanityConfig.ts +6 -0
  38. package/src/document/actions.ts +18 -11
  39. package/src/document/applyDocumentActions.test.ts +7 -6
  40. package/src/document/applyDocumentActions.ts +10 -4
  41. package/src/document/documentStore.test.ts +536 -188
  42. package/src/document/documentStore.ts +142 -76
  43. package/src/document/events.ts +7 -2
  44. package/src/document/permissions.test.ts +18 -16
  45. package/src/document/permissions.ts +35 -11
  46. package/src/document/processActions.test.ts +359 -32
  47. package/src/document/processActions.ts +104 -76
  48. package/src/document/reducers.test.ts +117 -29
  49. package/src/document/reducers.ts +43 -36
  50. package/src/document/sharedListener.ts +16 -6
  51. package/src/document/util.ts +14 -0
  52. package/src/favorites/favorites.test.ts +9 -2
  53. package/src/presence/bifurTransport.ts +6 -1
  54. package/src/preview/getPreviewState.test.ts +115 -98
  55. package/src/preview/getPreviewState.ts +38 -60
  56. package/src/preview/previewProjectionUtils.test.ts +179 -0
  57. package/src/preview/previewProjectionUtils.ts +93 -0
  58. package/src/preview/resolvePreview.test.ts +42 -25
  59. package/src/preview/resolvePreview.ts +29 -10
  60. package/src/preview/{previewStore.ts → types.ts} +8 -17
  61. package/src/projection/getProjectionState.test.ts +16 -16
  62. package/src/projection/getProjectionState.ts +2 -1
  63. package/src/projection/projectionQuery.ts +2 -3
  64. package/src/projection/types.ts +1 -1
  65. package/src/query/queryStore.ts +2 -1
  66. package/src/releases/getPerspectiveState.ts +7 -6
  67. package/src/releases/releasesStore.test.ts +20 -5
  68. package/src/releases/releasesStore.ts +20 -8
  69. package/src/store/createStateSourceAction.test.ts +62 -0
  70. package/src/store/createStateSourceAction.ts +34 -39
  71. package/src/telemetry/__telemetry__/sdk.telemetry.ts +42 -0
  72. package/src/telemetry/devMode.test.ts +52 -0
  73. package/src/telemetry/devMode.ts +40 -0
  74. package/src/telemetry/initTelemetry.test.ts +225 -0
  75. package/src/telemetry/initTelemetry.ts +205 -0
  76. package/src/telemetry/telemetryManager.test.ts +263 -0
  77. package/src/telemetry/telemetryManager.ts +187 -0
  78. package/src/users/usersStore.test.ts +1 -0
  79. package/src/users/usersStore.ts +5 -1
  80. package/src/utils/createFetcherStore.test.ts +6 -4
  81. package/src/utils/createFetcherStore.ts +2 -1
  82. package/src/utils/getStagingApiHost.test.ts +21 -0
  83. package/src/utils/getStagingApiHost.ts +14 -0
  84. package/src/utils/ids.test.ts +1 -29
  85. package/src/utils/ids.ts +0 -10
  86. package/src/utils/setCleanupTimeout.ts +24 -0
  87. package/src/preview/previewQuery.test.ts +0 -236
  88. package/src/preview/previewQuery.ts +0 -153
  89. package/src/preview/previewStore.test.ts +0 -36
  90. package/src/preview/subscribeToStateAndFetchBatches.test.ts +0 -221
  91. package/src/preview/subscribeToStateAndFetchBatches.ts +0 -112
  92. 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
+ }
@@ -451,6 +451,7 @@ describe('usersStore', () => {
451
451
  expect(specificRequest).toHaveBeenCalledWith({
452
452
  method: 'GET',
453
453
  uri: `/users/${projectUserId}`,
454
+ tag: 'users.get',
454
455
  })
455
456
 
456
457
  const expectedUser: SanityUser = {
@@ -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
- setTimeout(
317
+ setCleanupTimeout(
314
318
  () => state.set('removeSubscription', removeSubscription(subscriptionId, key)),
315
319
  USERS_STATE_CLEAR_DELAY,
316
320
  )