@goliapkg/sentori-react-native 6.4.1 → 7.0.1

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 (47) hide show
  1. package/README.md +25 -1
  2. package/android/src/main/java-core/com/sentori/SentoriConfig.kt +1 -1
  3. package/android/src/main/java-core/com/sentori/SentoriNativeSignals.kt +1 -2
  4. package/android/src/main/java-core/com/sentori/SentoriPush.kt +40 -13
  5. package/android/src/main/java-core/com/sentori/SentoriScope.kt +17 -1
  6. package/android/src/main/java-core/com/sentori/SentoriTransport.kt +30 -4
  7. package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +1 -1
  8. package/android/src/test/java-core/com/sentori/SentoriTransportTest.kt +82 -0
  9. package/ios/core/SentoriHangWatchdog.swift +3 -3
  10. package/ios/core/SentoriThreadSampler.swift +3 -3
  11. package/lib/handlers/network.js +2 -2
  12. package/lib/handlers/network.js.map +1 -1
  13. package/lib/index.js +1 -1
  14. package/lib/index.js.map +1 -1
  15. package/lib/init.js +2 -2
  16. package/lib/init.js.map +1 -1
  17. package/lib/long-task-monitor.js +1 -1
  18. package/lib/long-task-monitor.js.map +1 -1
  19. package/lib/mobile-vitals.js +2 -2
  20. package/lib/mobile-vitals.js.map +1 -1
  21. package/lib/push.d.ts +1 -1
  22. package/lib/push.d.ts.map +1 -1
  23. package/lib/push.js +47 -15
  24. package/lib/push.js.map +1 -1
  25. package/lib/rage-tap.js +2 -2
  26. package/lib/rage-tap.js.map +1 -1
  27. package/lib/scope.d.ts.map +1 -1
  28. package/lib/scope.js +18 -1
  29. package/lib/scope.js.map +1 -1
  30. package/lib/transport.js +2 -2
  31. package/lib/transport.js.map +1 -1
  32. package/lib/verbs.js +3 -3
  33. package/lib/verbs.js.map +1 -1
  34. package/package.json +2 -2
  35. package/src/__tests__/iron-rule.test.ts +79 -1
  36. package/src/__tests__/push.test.ts +46 -8
  37. package/src/handlers/network.ts +2 -2
  38. package/src/index.ts +1 -1
  39. package/src/init.ts +2 -2
  40. package/src/long-task-monitor.ts +1 -1
  41. package/src/mobile-vitals.ts +2 -2
  42. package/src/push.ts +51 -15
  43. package/src/rage-tap.tsx +2 -2
  44. package/src/scope.ts +19 -1
  45. package/src/transport.ts +2 -2
  46. package/src/verbs.ts +3 -3
  47. package/ios/PRIVACY_AND_REVIEW.md +0 -122
@@ -44,7 +44,7 @@ function grantingNative(over: Record<string, unknown> = {}) {
44
44
 
45
45
  const realFetch = globalThis.fetch;
46
46
 
47
- /** Answer /v1/push/tokens with `body` under `status`. */
47
+ /** Answer /v1/push/devices with `body` under `status`. */
48
48
  function stubFetch(status: number, body: unknown): void {
49
49
  globalThis.fetch = (() =>
50
50
  Promise.resolve({
@@ -73,7 +73,7 @@ describe('push.register', () => {
73
73
 
74
74
  it('returns the device handle when the whole flow works', async () => {
75
75
  __setNativeForTests(grantingNative());
76
- stubFetch(200, { token_id: '018f0000-0000-7000-8000-000000000001', is_new: true });
76
+ stubFetch(200, { spToken: '018f0000-0000-7000-8000-000000000001', isNew: true });
77
77
 
78
78
  const r = await register();
79
79
 
@@ -89,7 +89,7 @@ describe('push.register', () => {
89
89
  return Promise.resolve({
90
90
  ok: true,
91
91
  status: 200,
92
- json: () => Promise.resolve({ token_id: 'id-1' }),
92
+ json: () => Promise.resolve({ spToken: 'id-1' }),
93
93
  });
94
94
  }) as unknown as typeof fetch;
95
95
 
@@ -108,7 +108,7 @@ describe('push.register', () => {
108
108
  return Promise.resolve({
109
109
  ok: true,
110
110
  status: 200,
111
- json: () => Promise.resolve({ token_id: 'id-1' }),
111
+ json: () => Promise.resolve({ spToken: 'id-1' }),
112
112
  });
113
113
  }) as unknown as typeof fetch;
114
114
 
@@ -209,7 +209,7 @@ describe('push.register', () => {
209
209
  // the life of the install — and a send aimed at that user reached
210
210
  // nobody and reported success.
211
211
  describe('push registration follows the person', () => {
212
- /** Record every /v1/push/tokens body, so a test can say what the
212
+ /** Record every /v1/push/devices body, so a test can say what the
213
213
  * server was told rather than that it was told something. */
214
214
  function recordingFetch(): Array<Record<string, unknown>> {
215
215
  const seen: Array<Record<string, unknown>> = [];
@@ -220,7 +220,7 @@ describe('push registration follows the person', () => {
220
220
  return Promise.resolve({
221
221
  ok: true,
222
222
  status: 202,
223
- json: () => Promise.resolve({ spToken: 'dev-1', token_id: 'dev-1' }),
223
+ json: () => Promise.resolve({ spToken: 'dev-1' }),
224
224
  });
225
225
  }) as unknown as typeof fetch;
226
226
  return seen;
@@ -265,6 +265,44 @@ describe('push registration follows the person', () => {
265
265
  expect(seen[1]?.traits).toEqual({ plan: 'pro' });
266
266
  });
267
267
 
268
+ it('sends the identity when the person signs in while the registration is in flight', async () => {
269
+ // The window the test above cannot see. `register()` builds its
270
+ // body — with whoever is signed in at that moment, which is
271
+ // nobody — and only installs the identity listener once the
272
+ // response lands. A host that calls `user()` in between fired a
273
+ // listener that was not there yet, and nothing asked again: the
274
+ // device row kept no user, so a send aimed at that person matched
275
+ // no device and reported success.
276
+ //
277
+ // Registering push at launch and identifying the user right after
278
+ // is a few hundred milliseconds apart in a real app.
279
+ const seen: Array<Record<string, unknown>> = [];
280
+ globalThis.fetch = ((_url: string, init?: { body?: string }) => {
281
+ if (init?.body != null) {
282
+ seen.push(JSON.parse(init.body) as Record<string, unknown>);
283
+ // Sign in at the exact moment the first request goes out.
284
+ if (seen.length === 1) setUser({ id: 'usr_123', traits: { plan: 'pro' } });
285
+ }
286
+ // A request takes longer than hashing an id does, so the
287
+ // sign-in completes — and announces — while this is still in
288
+ // flight. That is the window, and a resolved promise skips it.
289
+ return new Promise((r) =>
290
+ setTimeout(
291
+ () => r({ ok: true, status: 202, json: () => Promise.resolve({ spToken: 'dev-1' }) }),
292
+ 5,
293
+ ),
294
+ );
295
+ }) as unknown as typeof fetch;
296
+
297
+ await register();
298
+ await settle();
299
+
300
+ expect(seen[0]?.userKey).toBeUndefined();
301
+ expect(seen).toHaveLength(2);
302
+ expect(typeof seen[1]?.userKey).toBe('string');
303
+ expect(seen[1]?.traits).toEqual({ plan: 'pro' });
304
+ });
305
+
268
306
  it('does not send anything when the same person is set again', async () => {
269
307
  const seen = recordingFetch();
270
308
  await register();
@@ -322,7 +360,7 @@ describe('push survives the vendor rotating its token', () => {
322
360
  return Promise.resolve({
323
361
  ok: true,
324
362
  status: 202,
325
- json: () => Promise.resolve({ spToken: 'dev-1', token_id: 'dev-1' }),
363
+ json: () => Promise.resolve({ spToken: 'dev-1' }),
326
364
  });
327
365
  }) as unknown as typeof fetch;
328
366
  return seen;
@@ -439,7 +477,7 @@ describe('nothing the host does can reach back into the app', () => {
439
477
  Promise.resolve({
440
478
  ok: true,
441
479
  status: 202,
442
- json: () => Promise.resolve({ spToken: 'dev-1', token_id: 'dev-1' }),
480
+ json: () => Promise.resolve({ spToken: 'dev-1' }),
443
481
  })) as unknown as typeof fetch;
444
482
  }
445
483
 
@@ -1,6 +1,6 @@
1
1
  // Network instrumentation — v1 role: feed the signal ring (`http`
2
- // signals) and detect the slow_api warn scenario (design.md §3,
3
- // category C: 「转圈不完」server-side flavour). The span/breadcrumb
2
+ // signals) and detect the slow_api warn scenario (category C:
3
+ // 「转圈不完」server-side flavour). The span/breadcrumb
4
4
  // machinery this file used to drive is gone with the APM vocabulary.
5
5
  //
6
6
  // Mini-spec (slow_api): a completed request slower than 3 s emits
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @goliapkg/sentori-react-native — the 8-verb surface (design.md §4).
1
+ // @goliapkg/sentori-react-native — the 8-verb surface.
2
2
  //
3
3
  // sentori.init(config) sentori.user(u) sentori.context(patch)
4
4
  // sentori.error(err) sentori.warn(name) sentori.trace(name)
package/src/init.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // sentori.init(config) — the single configuration entry point
2
- // (design.md §4). Synchronous, never throws; a bad config degrades
2
+ // Synchronous, never throws; a bad config degrades
3
3
  // every verb to a no-op with one console.warn, never a crash
4
4
  // (failure-isolation iron rule).
5
5
 
@@ -69,7 +69,7 @@ export const init = safeFn('init', (config: InitConfig): void => {
69
69
  installNetworkHandler();
70
70
  installLifecycleHandler();
71
71
 
72
- // Warn-scenario detectors (design.md §3 minimum set). rage_tap
72
+ // Warn-scenario detectors, the minimum set. rage_tap
73
73
  // rides the RageTapCapture component; the rest start here.
74
74
  if (config.detect?.longFreeze !== false) startLongTaskMonitor();
75
75
 
@@ -1,4 +1,4 @@
1
- // long_freeze — detected warn scenario (design.md §3, category B:
1
+ // long_freeze — detected warn scenario (category B:
2
2
  // 「app 卡住了 / 死了几秒」), JS-thread side.
3
3
  //
4
4
  // Mini-spec: a setInterval(50 ms) tick measures wall-clock drift.
@@ -1,5 +1,5 @@
1
- // slow_cold_start — detected warn scenario (design.md §3, category
2
- // C: 「启动过慢」).
1
+ // slow_cold_start — detected warn scenario (category C:
2
+ // 「启动过慢」).
3
3
  //
4
4
  // Mini-spec: the native side measures launch → JS-ready (iOS
5
5
  // mach_absolute_time / Android Process.getStartElapsedRealtime);
package/src/push.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  // asynchronously and lands in the native buffer.
11
11
  // 3. Poll `pushDrainState()` at 200 ms ticks for up to 8 s waiting
12
12
  // for the token.
13
- // 4. POST `/v1/push/tokens` with `kind: 'apns' | 'fcm'` — the
13
+ // 4. POST `/v1/push/devices` with `kind: 'apns' | 'fcm'` — the
14
14
  // server's field name; sending `provider` here earned a 422 for
15
15
  // every registration this SDK ever attempted — plus
16
16
  // `nativeToken`, `userKey`, and `env` on iOS only (FCM is a
@@ -73,7 +73,7 @@ let _onTap: PushRegisterOptions['onTap'] = undefined
73
73
  // v2.26 — confirmed delivery ack pipeline. msgIds extracted from
74
74
  // received pushes are queued here and flushed to the server every
75
75
  // 5 s. Server-side `push_sends.acked_at` flips from NULL to
76
- // wall-clock on first ack. See docs/roadmap/v2.26.md.
76
+ // wall-clock on first ack.
77
77
  const ACK_FLUSH_INTERVAL_MS = 5000
78
78
  let _ackQueue: string[] = []
79
79
  let _ackFlushInterval: ReturnType<typeof setInterval> | null = null
@@ -202,7 +202,13 @@ export async function register(opts: PushRegisterOptions = {}): Promise<PushRegi
202
202
  // successful registration as `server-rejected`.
203
203
  safely('onToken', () => opts.onToken?.(ipt))
204
204
  bindBufferDrain(opts.onMessage, opts.onTap)
205
- // From here on, signing in or out updates the row by itself.
205
+ // From here on, signing in or out updates the row by itself —
206
+ // including a sign-in that happened while this registration was
207
+ // still in flight, which announced to nobody and which `scope`
208
+ // replays as this listener is installed. It is replayed rather
209
+ // than read here because `user()` publishes its key a tick after
210
+ // the call, and a read at an arbitrary moment can catch a person
211
+ // half-described.
206
212
  onIdentityChange(reRegisterAfterIdentityChange)
207
213
  return { ok: true, ipt }
208
214
  } catch (e) {
@@ -235,11 +241,31 @@ function fail(
235
241
  * token. Nothing here throws and nothing here is awaited by the host:
236
242
  * `user()` is synchronous and stays that way.
237
243
  */
244
+ /** One string standing for "who this device belongs to", used only to
245
+ * answer "has it changed since the last request".
246
+ *
247
+ * Keys sorted: two objects holding the same pairs stringify in
248
+ * insertion order, so the same person described by two differently
249
+ * built objects would compare unequal — reporting a change that did
250
+ * not happen and sending a registration per call of a verb a host may
251
+ * call on every screen, which is the cost this comparison exists to
252
+ * avoid. Matches `identityString` in the Swift and Kotlin SDKs. */
253
+ function identityKey(
254
+ userKey: string | null | undefined,
255
+ traits: Record<string, unknown> | null | undefined,
256
+ ): string {
257
+ const rendered = Object.keys(traits ?? {})
258
+ .sort()
259
+ .map((k) => `${k}=${String((traits as Record<string, unknown>)[k])}`)
260
+ .join('\u0001')
261
+ return `${userKey ?? '-'}\u0000${rendered}`
262
+ }
263
+
238
264
  function reRegisterAfterIdentityChange(): void {
239
265
  const token = _lastNativeToken
240
266
  const cfg = tryGetRuntimeConfig()
241
267
  if (token == null || cfg == null) return
242
- const identity = JSON.stringify([currentUserKey() ?? null, currentUserTraits() ?? null])
268
+ const identity = identityKey(currentUserKey(), currentUserTraits())
243
269
  if (identity === _lastSentIdentity) return
244
270
  _lastSentIdentity = identity
245
271
  void registerWithServer(cfg, token, _lastOptions).catch((e: unknown) => {
@@ -262,7 +288,7 @@ class PushRegisterError extends Error {
262
288
  }
263
289
 
264
290
  /**
265
- * Revoke the cached handle (DELETE /v1/push/tokens/{ipt}) +
291
+ * Revoke the cached handle (DELETE /v1/push/devices/{ipt}) +
266
292
  * unregister locally. Idempotent — repeat calls are no-ops.
267
293
  */
268
294
  export async function unregister(): Promise<void> {
@@ -270,7 +296,7 @@ export async function unregister(): Promise<void> {
270
296
  const ipt = _cachedIpt ?? (await readPersistedIpt())
271
297
  if (cfg && ipt) {
272
298
  try {
273
- await fetch(joinUrl(cfg.ingestUrl, `/v1/push/tokens/${ipt}`), {
299
+ await fetch(joinUrl(cfg.ingestUrl, `/v1/push/devices/${ipt}`), {
274
300
  method: 'DELETE',
275
301
  headers: { authorization: `Bearer ${cfg.token}` },
276
302
  })
@@ -346,6 +372,11 @@ async function registerWithServer(
346
372
  : typeof __DEV__ !== 'undefined' && __DEV__
347
373
  ? 'sandbox'
348
374
  : 'production'
375
+ // Read once. The body and the record of what the body carried have
376
+ // to describe the same person, and two reads of a value the host
377
+ // can change between them do not.
378
+ const userKeyNow = currentUserKey()
379
+ const traitsNow = currentUserTraits()
349
380
  // `kind`, not `provider` — the server's field name, which this
350
381
  // sent for a year as `provider` and got a 422 for every time.
351
382
  const body: Record<string, unknown> = {
@@ -358,7 +389,7 @@ async function registerWithServer(
358
389
  // The same salted identity hash every event carries, so the
359
390
  // dashboard can address this device by the user who hit an
360
391
  // issue. Absent until the host calls `sentori.user()`.
361
- userKey: currentUserKey(),
392
+ userKey: userKeyNow,
362
393
  }
363
394
  if (env != null) body.env = env
364
395
  // `metadata` was an advertised option that no line of this file
@@ -372,9 +403,14 @@ async function registerWithServer(
372
403
  // a build channel called "pro" cannot answer a send aimed at the pro
373
404
  // plan. Absent leaves whatever the row already had; `{}` clears it,
374
405
  // which is what signing out sends.
375
- const traits = currentUserTraits()
376
- if (traits != null) body.traits = traits
377
- const res = await fetch(joinUrl(cfg.ingestUrl, '/v1/push/tokens'), {
406
+ if (traitsNow != null) body.traits = traitsNow
407
+ // What this request puts on the wire, recorded before it goes.
408
+ // `reRegisterAfterIdentityChange` reads it back to decide whether
409
+ // the person has changed since — including while this very request
410
+ // was in flight, which is the window a host hits by calling
411
+ // `sentori.user()` right after `register()`.
412
+ _lastSentIdentity = identityKey(userKeyNow, traitsNow)
413
+ const res = await fetch(joinUrl(cfg.ingestUrl, '/v1/push/devices'), {
378
414
  method: 'POST',
379
415
  headers: {
380
416
  authorization: `Bearer ${cfg.token}`,
@@ -383,16 +419,16 @@ async function registerWithServer(
383
419
  body: JSON.stringify(body),
384
420
  })
385
421
  if (!res.ok) {
386
- throw new PushRegisterError('server-rejected', `/v1/push/tokens HTTP ${res.status}`)
422
+ throw new PushRegisterError('server-rejected', `/v1/push/devices HTTP ${res.status}`)
387
423
  }
388
424
  // The handle is the device_tokens row id — a uuid, which is what
389
425
  // the revoke and send routes take. It was parsed here as an
390
426
  // `ipt_*` string that no server has ever returned.
391
- const json = (await res.json()) as { token_id?: string }
392
- if (typeof json.token_id !== 'string' || json.token_id.length === 0) {
427
+ const json = (await res.json()) as { spToken?: string }
428
+ if (typeof json.spToken !== 'string' || json.spToken.length === 0) {
393
429
  throw new PushRegisterError('server-rejected', 'server did not return a device token id')
394
430
  }
395
- return json.token_id
431
+ return json.spToken
396
432
  }
397
433
 
398
434
  function bindBufferDrain(
@@ -567,7 +603,7 @@ async function drainAckQueue(): Promise<void> {
567
603
  // the user flow.
568
604
  for (const msgId of batch) {
569
605
  try {
570
- await fetch(joinUrl(cfg.ingestUrl, `/v1/push/sends/${msgId}/ack`), {
606
+ await fetch(joinUrl(cfg.ingestUrl, `/v1/push/deliveries/${msgId}/ack`), {
571
607
  body: JSON.stringify({ eventType: 'received', sessionId: _sessionId }),
572
608
  headers: {
573
609
  authorization: `Bearer ${cfg.token}`,
package/src/rage-tap.tsx CHANGED
@@ -1,5 +1,5 @@
1
- // rage_tap — the first detected warn scenario (design.md §3,
2
- // category A: 「我按了没反应/反复按」).
1
+ // rage_tap — the first detected warn scenario (category A:
2
+ // 「我按了没反应/反复按」).
3
3
  //
4
4
  // Wrap the app root (next to ErrorBoundary) with
5
5
  // `<RageTapCapture>{children}</RageTapCapture>`. Bubble-phase
package/src/scope.ts CHANGED
@@ -23,14 +23,31 @@ let _traits: Record<string, unknown> | undefined;
23
23
  // this file; the arrow has to point one way.
24
24
  let _onIdentityChange: (() => void) | undefined;
25
25
 
26
+ /** An identity change that happened while nobody was listening.
27
+ * Push installs its listener only once a registration has landed, so
28
+ * a host that signs someone in while that request is in flight
29
+ * announced to an empty room — and nothing announces it again. */
30
+ let _missedAnnounce = false;
31
+
26
32
  /** Register interest in identity changes. Only push does. */
27
33
  export const onIdentityChange = (fn: (() => void) | undefined): void => {
28
34
  _onIdentityChange = fn;
35
+ if (fn != null && _missedAnnounce) {
36
+ _missedAnnounce = false;
37
+ announce();
38
+ }
29
39
  };
30
40
 
31
41
  const announce = (): void => {
42
+ if (_onIdentityChange == null) {
43
+ // Replayed by `onIdentityChange` when someone starts listening.
44
+ // Deliberately not a queue: identity is a current value, not a
45
+ // stream, so one flag replays the latest and no more.
46
+ _missedAnnounce = true;
47
+ return;
48
+ }
32
49
  try {
33
- _onIdentityChange?.();
50
+ _onIdentityChange();
34
51
  } catch {
35
52
  // NEVER rule: whatever the listener does, user() returns.
36
53
  }
@@ -95,5 +112,6 @@ export const __resetForTests = (): void => {
95
112
  _traits = undefined;
96
113
  _context = {};
97
114
  _onIdentityChange = undefined;
115
+ _missedAnnounce = false;
98
116
  _hashGeneration++;
99
117
  };
package/src/transport.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Event transport — the only place the SDK talks to the network.
2
2
  //
3
- // Quiet by default, complete when it matters (design.md §4): events
3
+ // Quiet by default, complete when it matters: events
4
4
  // batch on a 5 s timer or a 10-deep queue, whichever first; assert
5
5
  // pass-counts piggyback on whatever batch goes out next (never their
6
6
  // own request); failures back off and finally persist to an offline
@@ -20,7 +20,7 @@ const STORAGE_KEY = '@sentori/pending';
20
20
  const MAX_PERSISTED = 1000;
21
21
 
22
22
  // Pinned to package.json by a test — bump both together.
23
- const SDK_VERSION = '6.4.1';
23
+ const SDK_VERSION = '7.0.1';
24
24
 
25
25
  let _queue: WireEvent[] = [];
26
26
  let _assertStats = new Map<string, AssertStat>();
package/src/verbs.ts CHANGED
@@ -1,4 +1,4 @@
1
- // The five event verbs (design.md §4). Everything here is
1
+ // The five event verbs. Everything here is
2
2
  // synchronous, never throws, and returns the client-minted event id
3
3
  // — the zero-cost iron rule made code.
4
4
  //
@@ -36,7 +36,7 @@ import { countAssert, enqueue } from './transport';
36
36
  declare const __DEV__: boolean | undefined;
37
37
 
38
38
  /** Serialize any Error instances found in the data argument — the
39
- * error-in-data convention (design.md §4): a caught-but-noteworthy
39
+ * error-in-data convention: a caught-but-noteworthy
40
40
  * exception needs no special API. One level deep is enough; nested
41
41
  * containers of errors are an anti-pattern we don't reward. */
42
42
  const serializeData = (data?: EventData): Record<string, unknown> | undefined => {
@@ -220,7 +220,7 @@ export const assert = safeFn(
220
220
 
221
221
  export const probe = safeFn('probe', (ref: string, data?: EventData): string => {
222
222
  // A tripwire: reaching this call IS the signal. Never throws,
223
- // never changes control flow (design.md §4).
223
+ // never changes control flow.
224
224
  return emit('probe', { name: ref, data });
225
225
  });
226
226
 
@@ -1,122 +0,0 @@
1
- # iOS Main Thread Sampler — Privacy & App Store Review Notes
2
-
3
- > **Status:** Phase 29 sub-A step 1. Documents the Mach + pthread + dyld
4
- > APIs the upcoming `SentoriThreadSampler.swift` will call, the App
5
- > Store review risk for each, and the privacy boundary. Written before
6
- > the Swift implementation lands so we can rule API choices in or out
7
- > before writing code we'd later need to rip out.
8
-
9
- ## Why we need this
10
-
11
- `SentoriHangWatchdog.swift` currently captures
12
- `Thread.callStackSymbols` from the watchdog thread itself, not from
13
- main, because main is wedged when we want to sample it. The captured
14
- stack therefore points into our own timer machinery, which is useless
15
- for diagnosing the user's hang. (The file's own comment at line 100
16
- flags this as a stop-gap.)
17
-
18
- To get the actual wedged main-thread frames we have to walk a remote
19
- thread's frame pointer chain — main is alive but not running our
20
- code, so we resolve it via Mach + pthread APIs. This document checks
21
- those APIs against App Store Review Guideline 2.5.1 ("Apps must use
22
- only public APIs") before we commit to them.
23
-
24
- ## API inventory
25
-
26
- ### Public, App Store-safe
27
-
28
- | API | Header | Purpose | Risk |
29
- |---|---|---|---|
30
- | `pthread_main_np()` | `<pthread.h>` | bool: am I on main? Sanity-check before sampling. | none |
31
- | `pthread_self()` | `<pthread.h>` | get current pthread for `pthread_mach_thread_np`. | none |
32
- | `pthread_mach_thread_np(pthread_t)` | `<pthread.h>` | pthread → mach port. The `_np` suffix is Apple's mark for "non-portable extension"; it's a public API, just not POSIX-portable. | none |
33
- | `mach_task_self()` | `<mach/mach.h>` | this process's task port. We pass our own task only; we never look up another. | none |
34
- | `thread_get_state(thread, ARM_THREAD_STATE64, ...)` | `<mach/thread_act.h>` | read main thread's PC / FP / SP / LR. Same call sentry-cocoa, Firebase Crashlytics, and Bugsnag use. | low — public Mach API; reviewers expect to see it from crash-reporter SDKs. |
35
- | `vm_read_overwrite(self_task, addr, size, dst, ...)` | `<mach/vm_map.h>` | safe (no SIGSEGV) read of own-process memory. We restrict to `mach_task_self()` and small reads (each frame is two pointers). | low — public; flagged only when used to read other processes' memory. |
36
- | `_dyld_image_count()` / `_dyld_get_image_header()` / `_dyld_get_image_vmaddr_slide()` / `_dyld_get_image_uuid()` | `<mach-o/dyld.h>` | LC_UUID for dSYM matching, ASLR slide for offset calc. Public dyld API. | none |
37
-
38
- ### Explicitly NOT used (private API risk)
39
-
40
- | API | Why we don't use it |
41
- |---|---|
42
- | `_pthread_main_thread_np` (underscore prefix) | private alias of `pthread_main_np()`; underscore prefix in Apple SDK = SPI, rejection-grade. |
43
- | `task_threads(other_task, ...)` cross-task | requires `task-port` entitlement and is reviewer-flagged. We only ever look at our own task. |
44
- | `task_for_pid` | gated by entitlement; not appropriate for our same-process use. |
45
- | `__platform_call_*` / `kdebug_trace` / signal hooking | private. |
46
- | `_dyld_register_func_for_*` introspection beyond UUID | not needed for stack walking. |
47
-
48
- ## Why this is App Store safe
49
-
50
- Direct prior art shipping the same call set, no review issues:
51
-
52
- - **sentry-cocoa** — uses `thread_get_state` + `vm_read_overwrite` for
53
- slow-frame and ANR sampling, in millions of apps.
54
- - **Firebase Crashlytics** — same primitives for ANR + hang capture.
55
- - **Bugsnag**, **Embrace** — likewise.
56
- - **Apple's MetricKit** (`MXCallStackTree`) walks call stacks via the
57
- same public Darwin primitives. Apple themselves consume this
58
- surface.
59
-
60
- Apple's stance: Review Guideline 2.5.1 forbids non-public APIs, but
61
- "non-public" means undocumented / underscore-prefixed / not in the
62
- public SDK. All calls in the public-safe table above are documented
63
- in Apple's developer reference and shipped in `<mach/...>` /
64
- `<pthread.h>` / `<mach-o/dyld.h>` headers that come with Xcode by
65
- default.
66
-
67
- ## Privacy considerations
68
-
69
- What we capture per hang event:
70
-
71
- - Up to 64 PC values (program counter addresses) from the main
72
- thread's frame pointer chain.
73
- - The `LC_UUID` of each loaded image and its ASLR vmaddr slide, so
74
- the server can match PCs back to a dSYM (Phase 22 sub-B field).
75
- - Hang duration in milliseconds (already captured today).
76
-
77
- What we explicitly do NOT capture:
78
-
79
- - Other processes' memory. We only pass `mach_task_self()` to
80
- `vm_read_overwrite`.
81
- - Function arguments, local variables, or register contents — PC
82
- only, never the rest of the `arm_thread_state64_t` struct.
83
- - Heap content, NSString contents, user-typed text, PII.
84
- - Continuous-rate samples. Sampling fires only on a detected hang
85
- (≥ 2s main-thread block) and is one-shot per hang (see watchdog
86
- `reportedThisHang` flag at `SentoriHangWatchdog.swift:54`).
87
-
88
- Symbolication happens **server-side** against the uploaded dSYM
89
- (`server/src/symbolicate.rs`). On-device,
90
- `frames[].instructionAddress` is an ASLR-slid pointer with no
91
- semantic content until paired with the dSYM.
92
-
93
- For the user-facing privacy doc (what data Sentori collects and why),
94
- see `docs/legal/privacy.md`.
95
-
96
- ## Rejection contingency
97
-
98
- If Apple Review ever rejects with reference to `vm_read_overwrite` or
99
- `thread_get_state`:
100
-
101
- 1. Confirm sentry-cocoa / Firebase / Bugsnag are still shipping the
102
- same call set. They're the canary; rejection there means a policy
103
- change everyone needs to handle.
104
- 2. Switch to `backtrace()` from `<execinfo.h>` — pure libc, walks
105
- only the *current* thread, which is the watchdog thread, not
106
- main. Lower fidelity (back where we started), zero risk.
107
- 3. Last resort: ship without main-thread sampler; emit hang events
108
- with empty `frames[]` and `tags.source = "sentori.hangWatchdog.no-sampler"`
109
- so the dashboard can flag the gap. Feature degrades, doesn't
110
- break.
111
-
112
- ## Implementation references
113
-
114
- - `sdk/react-native/ios/SentoriThreadSampler.swift` — to be created in
115
- Phase 29 sub-A step 2: `captureMainThreadFrames(maxFrames: Int = 64) -> [(pc: UInt64, fp: UInt64)]`
116
- - `sdk/react-native/ios/SentoriHangWatchdog.swift` — current
117
- `Thread.callStackSymbols` capture (line 108) replaced by sampler
118
- call in step 4.
119
- - `server/src/symbolicate.rs` — dSYM lookup + frame resolution
120
- (existing, Phase 22 sub-B).
121
- - `docs/protocol.md` — `frames[].instructionAddress` + `debugId` +
122
- `arch` field definitions (existing, Phase 22 sub-B).