@sanity/sdk 3.0.0-rc.0 → 3.0.0-rc.2

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 (241) hide show
  1. package/README.md +12 -0
  2. package/dist/_exports/_internal.d.ts +163 -0
  3. package/dist/_exports/_internal.d.ts.map +1 -0
  4. package/dist/_exports/_internal.js +332 -0
  5. package/dist/_exports/_internal.js.map +1 -0
  6. package/dist/_exports/agent.d.ts +65 -0
  7. package/dist/_exports/agent.d.ts.map +1 -0
  8. package/dist/_exports/agent.js +71 -0
  9. package/dist/_exports/agent.js.map +1 -0
  10. package/dist/_exports/comlink.d.ts +118 -0
  11. package/dist/_exports/comlink.d.ts.map +1 -0
  12. package/dist/_exports/comlink.js +69 -0
  13. package/dist/_exports/comlink.js.map +1 -0
  14. package/dist/authStore-CtE9bLHW.js +754 -0
  15. package/dist/authStore-CtE9bLHW.js.map +1 -0
  16. package/dist/authStore-DKFX8fn7.d.ts +3179 -0
  17. package/dist/authStore-DKFX8fn7.d.ts.map +1 -0
  18. package/dist/clientStore-BtE2-q2i.js +90 -0
  19. package/dist/clientStore-BtE2-q2i.js.map +1 -0
  20. package/dist/createSanityInstance-DUI1bGIg.d.ts +278 -0
  21. package/dist/createSanityInstance-DUI1bGIg.d.ts.map +1 -0
  22. package/dist/createStateSourceAction-BOKtqFNm.js +300 -0
  23. package/dist/createStateSourceAction-BOKtqFNm.js.map +1 -0
  24. package/dist/getEnv-D5RGkQLu.js +8 -0
  25. package/dist/getEnv-D5RGkQLu.js.map +1 -0
  26. package/dist/getNodeState-CR3__B2D.js +67 -0
  27. package/dist/getNodeState-CR3__B2D.js.map +1 -0
  28. package/dist/index.d.ts +3 -2332
  29. package/dist/index.js +4171 -4332
  30. package/dist/index.js.map +1 -1
  31. package/dist/logger-DAtJb5RQ.js +173 -0
  32. package/dist/logger-DAtJb5RQ.js.map +1 -0
  33. package/dist/object-CxXlSVg7.js +83 -0
  34. package/dist/object-CxXlSVg7.js.map +1 -0
  35. package/dist/queryStore-DPf9sN3V.js +1346 -0
  36. package/dist/queryStore-DPf9sN3V.js.map +1 -0
  37. package/dist/rolldown-runtime-vyAXikos.js +9 -0
  38. package/dist/setCleanupTimeout-BiM6q1KF.js +31 -0
  39. package/dist/setCleanupTimeout-BiM6q1KF.js.map +1 -0
  40. package/dist/telemetryManager-Dq_qCkeI.js +127 -0
  41. package/dist/telemetryManager-Dq_qCkeI.js.map +1 -0
  42. package/dist/version-BChjsi4u.js +9 -0
  43. package/dist/version-BChjsi4u.js.map +1 -0
  44. package/package.json +47 -52
  45. package/src/_exports/_internal.ts +27 -0
  46. package/src/_exports/agent.ts +19 -0
  47. package/src/_exports/comlink.ts +16 -0
  48. package/src/_exports/index.ts +125 -64
  49. package/src/access/checkPermissions.test.ts +55 -0
  50. package/src/access/checkPermissions.ts +54 -0
  51. package/src/agent/agentActions.test.ts +16 -60
  52. package/src/agent/agentActions.ts +25 -38
  53. package/src/applications/applications.test-d.ts +88 -0
  54. package/src/applications/applications.test.ts +130 -0
  55. package/src/applications/applications.ts +367 -0
  56. package/src/auth/authLogger.ts +30 -0
  57. package/src/auth/authStore.test.ts +194 -13
  58. package/src/auth/authStore.ts +58 -26
  59. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  60. package/src/auth/getOrganizationVerificationState.ts +4 -4
  61. package/src/auth/handleAuthCallback.test.ts +35 -1
  62. package/src/auth/handleAuthCallback.ts +25 -6
  63. package/src/auth/logout.test.ts +75 -2
  64. package/src/auth/logout.ts +23 -4
  65. package/src/auth/refreshStampedToken.test.ts +219 -469
  66. package/src/auth/refreshStampedToken.ts +79 -195
  67. package/src/auth/studioAuth.ts +1 -1
  68. package/src/auth/studioModeAuth.ts +2 -1
  69. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  70. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  71. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  72. package/src/client/clientStore.test.ts +62 -37
  73. package/src/client/clientStore.ts +45 -75
  74. package/src/client/liveEvents.test.ts +151 -0
  75. package/src/client/liveEvents.ts +107 -0
  76. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  77. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  78. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  79. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  80. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  81. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  82. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  83. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  84. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  85. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  86. package/src/comlink/node/getNodeState.test.ts +1 -1
  87. package/src/comlink/node/getNodeState.ts +2 -1
  88. package/src/comments/addonDatasetStore.test.ts +215 -0
  89. package/src/comments/addonDatasetStore.ts +249 -0
  90. package/src/comments/buildCommentThreads.test.ts +83 -0
  91. package/src/comments/buildCommentThreads.ts +54 -0
  92. package/src/comments/commentActions.test.ts +540 -0
  93. package/src/comments/commentActions.ts +555 -0
  94. package/src/comments/commentFieldPath.test.ts +40 -0
  95. package/src/comments/commentFieldPath.ts +44 -0
  96. package/src/comments/commentsConstants.ts +80 -0
  97. package/src/comments/commentsStore.test.ts +419 -0
  98. package/src/comments/commentsStore.ts +483 -0
  99. package/src/comments/normalizeComment.test.ts +116 -0
  100. package/src/comments/normalizeComment.ts +45 -0
  101. package/src/comments/observeComments.test.ts +193 -0
  102. package/src/comments/observeComments.ts +113 -0
  103. package/src/comments/reducers.test.ts +277 -0
  104. package/src/comments/reducers.ts +316 -0
  105. package/src/comments/types.ts +249 -0
  106. package/src/comments/weakenReferences.test.ts +47 -0
  107. package/src/comments/weakenReferences.ts +29 -0
  108. package/src/config/__tests__/handles.test.ts +18 -12
  109. package/src/config/handles.ts +25 -7
  110. package/src/config/sanityConfig.ts +40 -82
  111. package/src/datasets/datasets.test.ts +2 -2
  112. package/src/datasets/datasets.ts +10 -4
  113. package/src/document/actions.test.ts +115 -33
  114. package/src/document/actions.ts +212 -15
  115. package/src/document/applyDocumentActions.test.ts +35 -13
  116. package/src/document/applyDocumentActions.ts +30 -9
  117. package/src/document/documentConstants.ts +15 -0
  118. package/src/document/documentStore.concurrency.test.ts +1306 -0
  119. package/src/document/documentStore.test.ts +753 -273
  120. package/src/document/documentStore.ts +197 -75
  121. package/src/document/events.test.ts +57 -2
  122. package/src/document/events.ts +82 -26
  123. package/src/document/listen.test.ts +56 -0
  124. package/src/document/listen.ts +74 -21
  125. package/src/document/listenerEventOperators.test.ts +311 -0
  126. package/src/document/listenerEventOperators.ts +217 -0
  127. package/src/document/patchOperations.test.ts +76 -2
  128. package/src/document/patchOperations.ts +60 -1
  129. package/src/document/permissions.test.ts +107 -60
  130. package/src/document/permissions.ts +44 -19
  131. package/src/document/processActions/create.ts +138 -0
  132. package/src/document/processActions/delete.ts +100 -0
  133. package/src/document/processActions/discard.ts +63 -0
  134. package/src/document/processActions/edit.ts +154 -0
  135. package/src/document/processActions/processActions.ts +218 -0
  136. package/src/document/processActions/publish.ts +120 -0
  137. package/src/document/processActions/releaseArchive.ts +77 -0
  138. package/src/document/processActions/releaseCreate.ts +59 -0
  139. package/src/document/processActions/releaseDelete.ts +65 -0
  140. package/src/document/processActions/releaseEdit.ts +37 -0
  141. package/src/document/processActions/releasePublish.ts +45 -0
  142. package/src/document/processActions/releaseSchedule.ts +87 -0
  143. package/src/document/processActions/releaseUtil.ts +31 -0
  144. package/src/document/processActions/shared.ts +308 -0
  145. package/src/document/processActions/unpublish.ts +85 -0
  146. package/src/document/processActions.test.ts +1129 -121
  147. package/src/document/reducers.test.ts +772 -64
  148. package/src/document/reducers.ts +205 -51
  149. package/src/document/resourceRules.test.ts +178 -0
  150. package/src/document/resourceRules.ts +117 -0
  151. package/src/document/sharedListener.test.ts +16 -14
  152. package/src/document/sharedListener.ts +24 -8
  153. package/src/document/util.test.ts +46 -0
  154. package/src/document/util.ts +50 -0
  155. package/src/favorites/favorites.test.ts +5 -6
  156. package/src/installations/installations.test-d.ts +73 -0
  157. package/src/installations/installations.test.ts +62 -0
  158. package/src/installations/installations.ts +206 -0
  159. package/src/organization/organization.test-d.ts +36 -0
  160. package/src/organization/organization.test.ts +138 -0
  161. package/src/organization/organization.ts +138 -0
  162. package/src/organizations/organizations.test-d.ts +56 -0
  163. package/src/organizations/organizations.test.ts +150 -0
  164. package/src/organizations/organizations.ts +107 -0
  165. package/src/presence/bifurTransport.test.ts +201 -9
  166. package/src/presence/bifurTransport.ts +98 -14
  167. package/src/presence/paths.test.ts +70 -0
  168. package/src/presence/paths.ts +41 -0
  169. package/src/presence/presenceStore.test.ts +915 -24
  170. package/src/presence/presenceStore.ts +576 -104
  171. package/src/presence/types.ts +175 -3
  172. package/src/preview/previewProjectionUtils.test.ts +9 -22
  173. package/src/preview/previewProjectionUtils.ts +7 -3
  174. package/src/preview/types.ts +0 -15
  175. package/src/project/project.test-d.ts +37 -0
  176. package/src/project/project.test.ts +157 -12
  177. package/src/project/project.ts +138 -25
  178. package/src/projection/getProjectionState.test.ts +18 -20
  179. package/src/projection/getProjectionState.ts +23 -3
  180. package/src/projection/projectionQuery.ts +5 -5
  181. package/src/projection/projectionStore.test.ts +1 -9
  182. package/src/projection/resolveProjection.test.ts +1 -2
  183. package/src/projection/resolveProjection.ts +16 -0
  184. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  185. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  186. package/src/projects/projects.test-d.ts +32 -0
  187. package/src/projects/projects.test.ts +108 -42
  188. package/src/projects/projects.ts +63 -27
  189. package/src/query/queryStore.test.ts +259 -123
  190. package/src/query/queryStore.ts +85 -69
  191. package/src/query/reducers.test.ts +0 -42
  192. package/src/query/reducers.ts +2 -18
  193. package/src/releases/getPerspectiveState.test.ts +17 -23
  194. package/src/releases/getPerspectiveState.ts +8 -20
  195. package/src/releases/observeReleases.test.ts +127 -0
  196. package/src/releases/observeReleases.ts +102 -0
  197. package/src/releases/releasesStore.test.ts +100 -53
  198. package/src/releases/releasesStore.ts +69 -44
  199. package/src/releases/utils/sortReleases.test.ts +38 -2
  200. package/src/releases/utils/sortReleases.ts +15 -10
  201. package/src/store/createActionBinder.test.ts +109 -90
  202. package/src/store/createActionBinder.ts +36 -28
  203. package/src/store/createSanityInstance.test.ts +15 -13
  204. package/src/store/createSanityInstance.ts +14 -2
  205. package/src/store/createStateSourceAction.test.ts +64 -2
  206. package/src/store/createStateSourceAction.ts +38 -43
  207. package/src/store/createStoreInstance.test.ts +4 -2
  208. package/src/store/fetcherStore.test-d.ts +121 -0
  209. package/src/store/fetcherStore.test.ts +299 -0
  210. package/src/store/fetcherStore.ts +801 -0
  211. package/src/telemetry/environment.test.ts +119 -0
  212. package/src/telemetry/environment.ts +92 -0
  213. package/src/telemetry/events.ts +42 -0
  214. package/src/telemetry/initTelemetry.test.ts +432 -0
  215. package/src/telemetry/initTelemetry.ts +218 -0
  216. package/src/telemetry/telemetryManager.test.ts +327 -0
  217. package/src/telemetry/telemetryManager.ts +199 -0
  218. package/src/users/reducers.test.ts +3 -1
  219. package/src/users/reducers.ts +5 -6
  220. package/src/users/types.ts +4 -4
  221. package/src/users/usersStore.test.ts +16 -12
  222. package/src/users/usersStore.ts +5 -1
  223. package/src/utils/buildQuery.test.ts +14 -0
  224. package/src/utils/buildQuery.ts +17 -0
  225. package/src/utils/createFetcherStore.test.ts +7 -5
  226. package/src/utils/createFetcherStore.ts +8 -5
  227. package/src/utils/getStagingApiHost.test.ts +21 -0
  228. package/src/utils/getStagingApiHost.ts +14 -0
  229. package/src/utils/ids.test.ts +1 -29
  230. package/src/utils/ids.ts +0 -10
  231. package/src/utils/includeShape.ts +36 -0
  232. package/src/utils/isImportError.test.ts +72 -0
  233. package/src/utils/isImportError.ts +34 -0
  234. package/src/utils/logger.test.ts +12 -0
  235. package/src/utils/logger.ts +8 -3
  236. package/src/utils/object.test.ts +95 -0
  237. package/src/utils/object.ts +142 -0
  238. package/src/utils/setCleanupTimeout.ts +37 -0
  239. package/src/document/processActions.ts +0 -707
  240. package/src/utils/defineIntent.test.ts +0 -477
  241. package/src/utils/defineIntent.ts +0 -244
@@ -0,0 +1,218 @@
1
+ import {type SanityInstance} from '../store/createSanityInstance'
2
+ import {createLogger} from '../utils/logger'
3
+ import {getTelemetryEnvironment} from './environment'
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 telemetry for a SDK instance if the runtime environment
24
+ * qualifies. The environment is resolved by `getTelemetryEnvironment()`:
25
+ *
26
+ * - `'development'` — local dev servers (`localhost` / `127.0.0.1`, or
27
+ * Node with `NODE_ENV=development`). This is the original opt-in
28
+ * surface.
29
+ * - `'production'` — apps deployed to Sanity-controlled domains
30
+ * (e.g. `*.sanity.studio`, the dashboard). End users are
31
+ * authenticated Sanity users with Populus consent records, so we
32
+ * apply the same consent gate as the Studio's `telemetry-sink`.
33
+ *
34
+ * Apps on customer-controlled domains return `null` and skip telemetry
35
+ * entirely.
36
+ *
37
+ * `telemetryManager` and `clientStore` are dynamically imported so the
38
+ * telemetry code path stays out of production bundles for apps that
39
+ * don't qualify. Only the lightweight environment check runs at boot.
40
+ *
41
+ * The `projectId` must be passed explicitly because the resource
42
+ * configuration is typically set by the React layer after the
43
+ * instance has already been created.
44
+ *
45
+ * @internal
46
+ */
47
+ export function initTelemetry(instance: SanityInstance, projectId: string): void {
48
+ const environment = getTelemetryEnvironment()
49
+ if (!environment) {
50
+ logger.trace('initTelemetry skipped: environment not eligible', {internal: true})
51
+ return
52
+ }
53
+ if (!projectId) {
54
+ logger.trace('initTelemetry skipped: no projectId', {internal: true})
55
+ return
56
+ }
57
+ if (telemetryManagers.has(instance) || initInFlight.has(instance)) {
58
+ return
59
+ }
60
+ initInFlight.add(instance)
61
+
62
+ logger.debug('initializing telemetry', {projectId, environment})
63
+
64
+ Promise.all([
65
+ import('./telemetryManager'),
66
+ import('../client/clientStore'),
67
+ import('../auth/authStore'),
68
+ ])
69
+ .then(async ([{createTelemetryManager}, {getClient}, {getTokenState}]) => {
70
+ if (instance.isDisposed()) {
71
+ initInFlight.delete(instance)
72
+ logger.debug('telemetry skipped: instance disposed before imports resolved')
73
+ return
74
+ }
75
+
76
+ // Wait for the auth store to resolve a token. The client needs a
77
+ // Bearer token for the consent check and batch POSTs. If the token
78
+ // is already available (e.g. static token config), this resolves
79
+ // immediately. For OAuth/localStorage discovery it waits for the
80
+ // first emission. For unauthenticated apps the promise never
81
+ // resolves, which is fine since telemetry requires auth anyway.
82
+ const token = getTokenState(instance).getCurrent()
83
+ logger.trace('auth token check', {tokenPresent: !!token, internal: true})
84
+
85
+ if (!token) {
86
+ logger.debug('waiting for auth token')
87
+ const hasToken = await new Promise<boolean>((resolve) => {
88
+ if (instance.isDisposed()) return resolve(false)
89
+ const cleanup = {unsubscribe: () => {}}
90
+ const unsub = instance.onDispose(() => {
91
+ cleanup.unsubscribe()
92
+ resolve(false)
93
+ })
94
+ // The token observable is a `shareReplay({bufferSize: 1, refCount: true})`
95
+ // (see `createStateSourceAction`), so it can deliver a buffered value
96
+ // synchronously while we're still inside `.subscribe(cb)`. At that
97
+ // moment `sub` is in the TDZ, so the callback can't reach it. We
98
+ // gate on a `received` flag and let the post-subscribe block do the
99
+ // unsubscribe in the sync-emission case.
100
+ let received = false
101
+ const sub = getTokenState(instance).observable.subscribe((t) => {
102
+ if (received || !t) return
103
+ received = true
104
+ logger.debug('auth token received')
105
+ unsub()
106
+ resolve(true)
107
+ cleanup.unsubscribe()
108
+ })
109
+ cleanup.unsubscribe = () => sub.unsubscribe()
110
+ if (received) cleanup.unsubscribe()
111
+ })
112
+ if (!hasToken || instance.isDisposed()) {
113
+ initInFlight.delete(instance)
114
+ logger.debug('telemetry skipped: no token resolved or instance disposed')
115
+ return
116
+ }
117
+ }
118
+
119
+ const manager = createTelemetryManager({
120
+ sessionId: instance.instanceId,
121
+ getClient: () => getClient(instance, {apiVersion: DEFAULT_TELEMETRY_API_VERSION}),
122
+ projectId,
123
+ environment,
124
+ })
125
+
126
+ const consented = await manager.checkConsent()
127
+ logger.debug('consent check complete', {consented})
128
+ if (!consented || instance.isDisposed()) {
129
+ initInFlight.delete(instance)
130
+ manager.dispose()
131
+ return
132
+ }
133
+
134
+ initInFlight.delete(instance)
135
+ telemetryManagers.set(instance, manager)
136
+
137
+ const buffered = pendingHooks.get(instance)
138
+ if (buffered) {
139
+ logger.debug('flushing buffered hooks', {hooks: Array.from(buffered)})
140
+ for (const hookName of buffered) {
141
+ manager.logHookFirstUsed(hookName)
142
+ }
143
+ pendingHooks.delete(instance)
144
+ }
145
+
146
+ const config = instance.config
147
+ const perspective = typeof config.perspective === 'string' ? config.perspective : 'published'
148
+ const authMethod = config.auth?.token
149
+ ? 'token'
150
+ : config.studio?.auth?.token
151
+ ? 'studio'
152
+ : 'default'
153
+
154
+ logger.info('telemetry session started', {projectId, perspective, authMethod, environment})
155
+ manager.logSessionStarted({
156
+ projectId,
157
+ perspective,
158
+ authMethod,
159
+ })
160
+
161
+ instance.onDispose(() => {
162
+ manager.endSession()
163
+ telemetryManagers.delete(instance)
164
+ logger.debug('telemetry session ended')
165
+ })
166
+ })
167
+ .catch((err) => {
168
+ initInFlight.delete(instance)
169
+ logger.warn('telemetry init failed', {error: err})
170
+ })
171
+ }
172
+
173
+ /**
174
+ * Retrieves the telemetry manager for an instance, if one exists.
175
+ * Returns undefined when telemetry is disabled or not yet initialized.
176
+ *
177
+ * @internal
178
+ */
179
+ export function getTelemetryManager(instance: SanityInstance): TelemetryManager | undefined {
180
+ return telemetryManagers.get(instance)
181
+ }
182
+
183
+ /**
184
+ * Record a hook name for an instance. If the telemetry manager is
185
+ * already initialized the event is logged immediately. Otherwise
186
+ * the name is buffered and flushed when init completes.
187
+ *
188
+ * @internal
189
+ */
190
+ export function trackHookMounted(instance: SanityInstance, hookName: string): void {
191
+ if (!getTelemetryEnvironment()) return
192
+
193
+ const manager = findManager(instance)
194
+ if (manager) {
195
+ logger.trace('hook mounted (logged)', {hookName, internal: true})
196
+ manager.logHookFirstUsed(hookName)
197
+ return
198
+ }
199
+
200
+ const root = getRootInstance(instance)
201
+ let hooks = pendingHooks.get(root)
202
+ if (!hooks) {
203
+ hooks = new Set()
204
+ pendingHooks.set(root, hooks)
205
+ }
206
+ if (!hooks.has(hookName)) {
207
+ logger.trace('hook mounted (buffered)', {hookName, internal: true})
208
+ }
209
+ hooks.add(hookName)
210
+ }
211
+
212
+ function findManager(instance: SanityInstance): TelemetryManager | undefined {
213
+ return telemetryManagers.get(instance)
214
+ }
215
+
216
+ function getRootInstance(instance: SanityInstance): SanityInstance {
217
+ return instance
218
+ }
@@ -0,0 +1,327 @@
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
+ const baseOptions = {
42
+ sessionId: 'test-session-id',
43
+ getClient,
44
+ projectId: 'abc123',
45
+ environment: 'development' as const,
46
+ }
47
+
48
+ beforeEach(() => {
49
+ vi.clearAllMocks()
50
+ })
51
+
52
+ afterEach(() => {
53
+ vi.restoreAllMocks()
54
+ })
55
+
56
+ it('creates a batched store with the given session ID', () => {
57
+ createTelemetryManager(baseOptions)
58
+
59
+ expect(createBatchedStore).toHaveBeenCalledWith(
60
+ 'test-session-id',
61
+ expect.objectContaining({
62
+ flushInterval: 30_000,
63
+ }),
64
+ )
65
+ })
66
+
67
+ it('logs session started with SDK version and provided data', () => {
68
+ const manager = createTelemetryManager(baseOptions)
69
+
70
+ const storeInstance = vi.mocked(createBatchedStore).mock.results[0].value
71
+ const logger = storeInstance.logger
72
+
73
+ manager.logSessionStarted({
74
+ projectId: 'abc123',
75
+ perspective: 'published',
76
+ authMethod: 'token',
77
+ })
78
+
79
+ expect(logger.log).toHaveBeenCalledWith(
80
+ expect.objectContaining({name: 'SDK Session Started'}),
81
+ expect.objectContaining({
82
+ version: '2.8.0-test',
83
+ projectId: 'abc123',
84
+ perspective: 'published',
85
+ authMethod: 'token',
86
+ }),
87
+ )
88
+ })
89
+
90
+ it('deduplicates hook first-used events by name', () => {
91
+ const manager = createTelemetryManager(baseOptions)
92
+
93
+ const storeInstance = vi.mocked(createBatchedStore).mock.results[0].value
94
+ const logger = storeInstance.logger
95
+
96
+ manager.logHookFirstUsed('useQuery')
97
+ manager.logHookFirstUsed('useQuery')
98
+ manager.logHookFirstUsed('useDocument')
99
+
100
+ const hookCalls = vi
101
+ .mocked(logger.log)
102
+ .mock.calls.filter(([event]: [{name: string}]) => event.name === 'SDK Hook Mounted')
103
+ expect(hookCalls).toHaveLength(2)
104
+ expect(hookCalls[0][1]).toEqual({hookName: 'useQuery'})
105
+ expect(hookCalls[1][1]).toEqual({hookName: 'useDocument'})
106
+ })
107
+
108
+ it('tracks hooksUsed set', () => {
109
+ const manager = createTelemetryManager(baseOptions)
110
+
111
+ manager.logHookFirstUsed('useQuery')
112
+ manager.logHookFirstUsed('useDocument')
113
+
114
+ expect(manager.hooksUsed).toEqual(new Set(['useQuery', 'useDocument']))
115
+ })
116
+
117
+ it('logs error events', () => {
118
+ const manager = createTelemetryManager(baseOptions)
119
+
120
+ const storeInstance = vi.mocked(createBatchedStore).mock.results[0].value
121
+ const logger = storeInstance.logger
122
+
123
+ manager.logError('TypeError', 'documentStore')
124
+
125
+ expect(logger.log).toHaveBeenCalledWith(expect.objectContaining({name: 'SDK Error'}), {
126
+ errorType: 'TypeError',
127
+ hookName: 'documentStore',
128
+ })
129
+ })
130
+
131
+ it('logs session ended with duration and hooksUsed on endSession', () => {
132
+ vi.useFakeTimers()
133
+
134
+ const manager = createTelemetryManager(baseOptions)
135
+
136
+ const storeInstance = vi.mocked(createBatchedStore).mock.results[0].value
137
+ const logger = storeInstance.logger
138
+
139
+ manager.logHookFirstUsed('useQuery')
140
+
141
+ vi.advanceTimersByTime(5000)
142
+
143
+ manager.endSession()
144
+
145
+ expect(logger.log).toHaveBeenCalledWith(
146
+ expect.objectContaining({name: 'SDK Session Ended'}),
147
+ expect.objectContaining({
148
+ durationSeconds: 5,
149
+ hooksUsed: ['useQuery'],
150
+ }),
151
+ )
152
+
153
+ vi.useRealTimers()
154
+ })
155
+
156
+ describe('environment context', () => {
157
+ type EnrichedContext = {
158
+ environment?: string
159
+ version?: string
160
+ origin?: string
161
+ traceCorrelationId?: string
162
+ }
163
+ type EnrichedEvent = {
164
+ type?: string
165
+ name?: string
166
+ version?: number
167
+ traceId?: string
168
+ sessionId?: string
169
+ createdAt?: string
170
+ data?: unknown
171
+ context: EnrichedContext
172
+ }
173
+ const getRequestBody = () => {
174
+ const [args] = mockClient.request.mock.calls as unknown as Array<
175
+ [{body: {batch: EnrichedEvent[]}}]
176
+ >
177
+ return args[0].body
178
+ }
179
+
180
+ it('records "development" in the enriched batch context and preserves the event payload', async () => {
181
+ createTelemetryManager({...baseOptions, environment: 'development'})
182
+
183
+ const storeOptions = vi.mocked(createBatchedStore).mock.calls[0][1]
184
+ await storeOptions.sendEvents([
185
+ {
186
+ type: 'log',
187
+ version: 1,
188
+ name: 'SDK Session Started',
189
+ sessionId: 'test-session-id',
190
+ createdAt: '2026-01-01T00:00:00Z',
191
+ data: {projectId: 'p1'},
192
+ } as never,
193
+ ])
194
+
195
+ const [event] = getRequestBody().batch
196
+ expect(event.context.environment).toBe('development')
197
+ expect(event.type).toBe('log')
198
+ expect(event.name).toBe('SDK Session Started')
199
+ expect(event.version).toBe(1)
200
+ expect(event.sessionId).toBe('test-session-id')
201
+ expect(event.data).toEqual({projectId: 'p1'})
202
+ })
203
+
204
+ it('records "production" in the enriched batch context', async () => {
205
+ createTelemetryManager({...baseOptions, environment: 'production'})
206
+
207
+ const storeOptions = vi.mocked(createBatchedStore).mock.calls[0][1]
208
+ await storeOptions.sendEvents([
209
+ {
210
+ type: 'log',
211
+ version: 1,
212
+ name: 'SDK Session Started',
213
+ sessionId: 'test-session-id',
214
+ createdAt: '2026-01-01T00:00:00Z',
215
+ data: {},
216
+ } as never,
217
+ ])
218
+
219
+ expect(getRequestBody().batch[0].context.environment).toBe('production')
220
+ })
221
+
222
+ it('preserves pre-existing context fields from trace events and lets SDK fields take precedence on conflict', async () => {
223
+ // Trace events from `@sanity/telemetry` arrive with a top-level
224
+ // `context` (see `TelemetryTraceStartEvent`, etc.). Replacing rather
225
+ // than merging would silently drop those fields. The SDK-managed
226
+ // fields (`version`, `environment`, `origin`) should also win on key
227
+ // collision so they remain authoritative. The rest of the trace
228
+ // event (`type`, `name`, `traceId`, `sessionId`, `createdAt`) must
229
+ // survive — downstream consumers (Rudderstack via telemetry-sink)
230
+ // read those fields directly off each batch entry.
231
+ createTelemetryManager({...baseOptions, environment: 'production'})
232
+
233
+ const storeOptions = vi.mocked(createBatchedStore).mock.calls[0][1]
234
+ await storeOptions.sendEvents([
235
+ {
236
+ type: 'trace.start',
237
+ name: 'some.trace',
238
+ version: 1,
239
+ traceId: 't1',
240
+ sessionId: 'test-session-id',
241
+ createdAt: '2026-01-01T00:00:00Z',
242
+ context: {
243
+ traceCorrelationId: 'abc-123',
244
+ environment: 'should-be-overwritten',
245
+ },
246
+ } as never,
247
+ ])
248
+
249
+ const [event] = getRequestBody().batch
250
+ expect(event.context.traceCorrelationId).toBe('abc-123')
251
+ expect(event.context.environment).toBe('production')
252
+ expect(event.context.version).toBe('2.8.0-test')
253
+ expect(event.type).toBe('trace.start')
254
+ expect(event.name).toBe('some.trace')
255
+ expect(event.traceId).toBe('t1')
256
+ expect(event.sessionId).toBe('test-session-id')
257
+ expect(event.createdAt).toBe('2026-01-01T00:00:00Z')
258
+ })
259
+ })
260
+
261
+ describe('endSession teardown', () => {
262
+ it('always uses flush + end (no sendBeacon due to auth header limitation)', () => {
263
+ const manager = createTelemetryManager(baseOptions)
264
+
265
+ const storeInstance = vi.mocked(createBatchedStore).mock.results[0].value
266
+
267
+ manager.endSession()
268
+
269
+ expect(storeInstance.flush).toHaveBeenCalled()
270
+ expect(storeInstance.end).toHaveBeenCalled()
271
+ })
272
+ })
273
+
274
+ describe('consent resolution', () => {
275
+ it('checkConsent returns true when user has opted in', async () => {
276
+ mockClient.request.mockResolvedValue({status: 'granted'})
277
+
278
+ const manager = createTelemetryManager(baseOptions)
279
+
280
+ const result = await manager.checkConsent()
281
+ expect(result).toBe(true)
282
+ expect(mockClient.request).toHaveBeenCalledWith(
283
+ expect.objectContaining({uri: '/intake/telemetry-status'}),
284
+ )
285
+ })
286
+
287
+ it('checkConsent returns false when user has denied telemetry', async () => {
288
+ mockClient.request.mockResolvedValue({status: 'denied'})
289
+
290
+ const manager = createTelemetryManager(baseOptions)
291
+
292
+ expect(await manager.checkConsent()).toBe(false)
293
+ })
294
+
295
+ it('checkConsent returns false when consent is unset', async () => {
296
+ mockClient.request.mockResolvedValue({status: 'unset'})
297
+
298
+ const manager = createTelemetryManager(baseOptions)
299
+
300
+ expect(await manager.checkConsent()).toBe(false)
301
+ })
302
+
303
+ it('checkConsent returns false on network failure', async () => {
304
+ mockClient.request.mockRejectedValue(new Error('Network error'))
305
+
306
+ const manager = createTelemetryManager(baseOptions)
307
+
308
+ expect(await manager.checkConsent()).toBe(false)
309
+ })
310
+
311
+ it('caches consent after the first call', async () => {
312
+ mockClient.request.mockResolvedValue({status: 'granted'})
313
+
314
+ createTelemetryManager(baseOptions)
315
+
316
+ const storeOptions = vi.mocked(createBatchedStore).mock.calls[0][1]
317
+ const resolveConsent = storeOptions.resolveConsent
318
+
319
+ const first = await resolveConsent()
320
+ const second = await resolveConsent()
321
+
322
+ expect(first).toEqual({status: 'granted'})
323
+ expect(second).toEqual({status: 'granted'})
324
+ expect(mockClient.request).toHaveBeenCalledTimes(1)
325
+ })
326
+ })
327
+ })