@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.
- package/README.md +25 -1
- package/android/src/main/java-core/com/sentori/SentoriConfig.kt +1 -1
- package/android/src/main/java-core/com/sentori/SentoriNativeSignals.kt +1 -2
- package/android/src/main/java-core/com/sentori/SentoriPush.kt +40 -13
- package/android/src/main/java-core/com/sentori/SentoriScope.kt +17 -1
- package/android/src/main/java-core/com/sentori/SentoriTransport.kt +30 -4
- package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +1 -1
- package/android/src/test/java-core/com/sentori/SentoriTransportTest.kt +82 -0
- package/ios/core/SentoriHangWatchdog.swift +3 -3
- package/ios/core/SentoriThreadSampler.swift +3 -3
- package/lib/handlers/network.js +2 -2
- package/lib/handlers/network.js.map +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/init.js +2 -2
- package/lib/init.js.map +1 -1
- package/lib/long-task-monitor.js +1 -1
- package/lib/long-task-monitor.js.map +1 -1
- package/lib/mobile-vitals.js +2 -2
- package/lib/mobile-vitals.js.map +1 -1
- package/lib/push.d.ts +1 -1
- package/lib/push.d.ts.map +1 -1
- package/lib/push.js +47 -15
- package/lib/push.js.map +1 -1
- package/lib/rage-tap.js +2 -2
- package/lib/rage-tap.js.map +1 -1
- package/lib/scope.d.ts.map +1 -1
- package/lib/scope.js +18 -1
- package/lib/scope.js.map +1 -1
- package/lib/transport.js +2 -2
- package/lib/transport.js.map +1 -1
- package/lib/verbs.js +3 -3
- package/lib/verbs.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/iron-rule.test.ts +79 -1
- package/src/__tests__/push.test.ts +46 -8
- package/src/handlers/network.ts +2 -2
- package/src/index.ts +1 -1
- package/src/init.ts +2 -2
- package/src/long-task-monitor.ts +1 -1
- package/src/mobile-vitals.ts +2 -2
- package/src/push.ts +51 -15
- package/src/rage-tap.tsx +2 -2
- package/src/scope.ts +19 -1
- package/src/transport.ts +2 -2
- package/src/verbs.ts +3 -3
- 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/
|
|
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, {
|
|
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({
|
|
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({
|
|
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/
|
|
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'
|
|
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'
|
|
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'
|
|
480
|
+
json: () => Promise.resolve({ spToken: 'dev-1' }),
|
|
443
481
|
})) as unknown as typeof fetch;
|
|
444
482
|
}
|
|
445
483
|
|
package/src/handlers/network.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Network instrumentation — v1 role: feed the signal ring (`http`
|
|
2
|
-
// signals) and detect the slow_api warn scenario (
|
|
3
|
-
//
|
|
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
package/src/init.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// sentori.init(config) — the single configuration entry point
|
|
2
|
-
//
|
|
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
|
|
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
|
|
package/src/long-task-monitor.ts
CHANGED
package/src/mobile-vitals.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// slow_cold_start — detected warn scenario (
|
|
2
|
-
//
|
|
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/
|
|
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.
|
|
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 =
|
|
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/
|
|
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/
|
|
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:
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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/
|
|
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 {
|
|
392
|
-
if (typeof json.
|
|
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.
|
|
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/
|
|
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 (
|
|
2
|
-
//
|
|
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
|
|
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 = '
|
|
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
|
|
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
|
|
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
|
|
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).
|