@keyframerdev/vitals 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -618,9 +618,13 @@ function slimArg(a, idOf) {
618
618
  return null;
619
619
  }
620
620
 
621
+ // ../protocol/src/vitals-validate.ts
622
+ var VITALS_MAX_HZ = 240;
623
+ var MAX_FRAME_MS = 10 * 60 * 1000;
624
+
621
625
  // src/version.ts
622
626
  var SDK_NAME = "@keyframerdev/vitals";
623
- var SDK_VERSION = "0.1.1";
627
+ var SDK_VERSION = "0.1.3";
624
628
 
625
629
  // src/controller.ts
626
630
  var STORAGE_KEYS = {
@@ -707,6 +711,11 @@ class VitalsController {
707
711
  meters = null;
708
712
  screen = "app";
709
713
  reduceMotion = false;
714
+ traced = {};
715
+ tracedAnims = 0;
716
+ metering = false;
717
+ drawing = true;
718
+ toldDevelopment = null;
710
719
  constructor(env) {
711
720
  this.env = env;
712
721
  }
@@ -736,7 +745,8 @@ class VitalsController {
736
745
  this.enabled = false;
737
746
  return;
738
747
  }
739
- this.config = cached?.config ?? null;
748
+ const dev = opts.environment === "development";
749
+ this.config = dev ? null : cached?.config ?? null;
740
750
  if (!this.allowed()) {
741
751
  this.enabled = false;
742
752
  return;
@@ -753,7 +763,7 @@ class VitalsController {
753
763
  return;
754
764
  this.retryStep = 0;
755
765
  this.scheduleRetry();
756
- const fresh = cached && this.env.wall() - cached.at < Math.max(60, cached.config.ttlSec) * 1000;
766
+ const fresh = !dev && cached && this.env.wall() - cached.at < Math.max(60, cached.config.ttlSec) * 1000;
757
767
  if (!fresh)
758
768
  this.configTimer = this.env.setTimeout(() => void this.fetchConfig(run), START_DELAY_MS);
759
769
  await this.beginSession(run);
@@ -803,6 +813,7 @@ class VitalsController {
803
813
  }
804
814
  }
805
815
  this.sampled = sampled;
816
+ this.debug(`session ${this.nonce.slice(0, 6)}: ${sampled ? "sampled" : "not sampled"} (rate ${this.sampleRate}, environment ${env}` + `${devOk ? "" : ", the project does not collect development builds"})`);
806
817
  if (!sampled)
807
818
  return;
808
819
  this.collector.clear();
@@ -815,6 +826,7 @@ class VitalsController {
815
826
  this.meters.install();
816
827
  }
817
828
  endSampling() {
829
+ this.metering = false;
818
830
  if (this.sampled) {
819
831
  this.meters?.stop();
820
832
  this.meters?.uninstall();
@@ -829,10 +841,17 @@ class VitalsController {
829
841
  this.checkpointTimer = null;
830
842
  }
831
843
  }
832
- appState(active) {
844
+ appState(active, drawing = active) {
833
845
  if (!this.enabled)
834
846
  return;
835
847
  const now = this.env.now();
848
+ if (this.drawing !== drawing) {
849
+ this.drawing = drawing;
850
+ if (!drawing && this.sampled && this.foreground) {
851
+ this.pauseMeters(now);
852
+ this.debug("app inactive: meters paused until it is active again");
853
+ }
854
+ }
836
855
  if (!active && this.foreground) {
837
856
  this.foreground = false;
838
857
  this.backgroundAt = now;
@@ -841,8 +860,7 @@ class VitalsController {
841
860
  this.retryTimer = null;
842
861
  }
843
862
  if (this.sampled) {
844
- this.gate.reset();
845
- this.meters?.stop();
863
+ this.pauseMeters(now);
846
864
  this.collector.foreground(false, now);
847
865
  this.closeWindow("background");
848
866
  }
@@ -882,6 +900,8 @@ class VitalsController {
882
900
  if (isRef2(a))
883
901
  this.collector.consume(a.$anim);
884
902
  this.collector.animStart(site, t, ms, thread, eid);
903
+ if (this.opts?.debug)
904
+ this.tracedAnims++;
885
905
  this.motion(t + ms, t);
886
906
  }
887
907
  layout(site, kind, builder, t = this.env.now()) {
@@ -905,17 +925,35 @@ class VitalsController {
905
925
  if (!this.sampled || !this.foreground)
906
926
  return;
907
927
  this.collector.touch(down, t);
928
+ if (!this.drawing)
929
+ return;
908
930
  if (this.gate.touch(down, t))
909
931
  this.opened(t);
910
932
  this.keepUi(t);
911
933
  }
912
934
  motion(end, now) {
935
+ if (!this.drawing)
936
+ return;
913
937
  if (this.gate.note(end, now))
914
938
  this.opened(now);
915
939
  this.keepUi(now);
916
940
  this.checkWindow(now);
917
941
  }
942
+ pauseMeters(now) {
943
+ this.gate.reset();
944
+ this.meters?.stop();
945
+ this.collector.measure(false, now);
946
+ if (this.metering && this.opts?.debug) {
947
+ this.flushTrace();
948
+ this.debug("meters off: the app left the screen");
949
+ }
950
+ this.metering = false;
951
+ this.uiKnownUntil = -Infinity;
952
+ }
918
953
  opened(now) {
954
+ if (!this.metering)
955
+ this.debug("meters on: something moves");
956
+ this.metering = true;
919
957
  this.collector.measure(true, now);
920
958
  this.meters?.start();
921
959
  this.scheduleCheckpoint();
@@ -929,12 +967,20 @@ class VitalsController {
929
967
  }
930
968
  }
931
969
  jsFrame(t, dt, budget) {
932
- if (!this.sampled || !this.foreground)
970
+ if (!this.sampled || !this.foreground || !this.drawing)
933
971
  return false;
934
- if (dt > 0)
972
+ if (dt > 0) {
935
973
  this.collector.frame("js", t, dt, budget);
974
+ if (this.opts?.debug)
975
+ this.traceFrame("js", dt, t);
976
+ }
936
977
  if (!this.gate.open(t)) {
937
978
  this.collector.measure(false, Math.min(t, this.gate.until));
979
+ if (this.metering && this.opts?.debug) {
980
+ this.flushTrace();
981
+ this.debug("meters off: the motion window closed");
982
+ }
983
+ this.metering = false;
938
984
  return false;
939
985
  }
940
986
  this.keepUi(t);
@@ -946,6 +992,49 @@ class VitalsController {
946
992
  return;
947
993
  for (let i = 0;i + 2 < buf.length; i += 3)
948
994
  this.collector.frame("ui", buf[i], buf[i + 1], buf[i + 2]);
995
+ if (this.opts?.debug)
996
+ for (let i = 0;i + 2 < buf.length; i += 3)
997
+ this.traceFrame("ui", buf[i + 1], buf[i]);
998
+ }
999
+ traceFrame(thread, dt, t) {
1000
+ const cur = this.traced[thread];
1001
+ if (cur && (t - cur.since >= 1000 || t - dt > cur.last + 100))
1002
+ this.flushTrace();
1003
+ const s = this.traced[thread] ??= { n: 0, sum: 0, min: Infinity, max: 0, since: t - dt, last: t };
1004
+ s.n++;
1005
+ s.sum += dt;
1006
+ if (dt < s.min)
1007
+ s.min = dt;
1008
+ if (dt > s.max)
1009
+ s.max = dt;
1010
+ s.since = Math.min(s.since, t - dt);
1011
+ s.last = Math.max(s.last, t);
1012
+ }
1013
+ flushTrace() {
1014
+ const all = Object.values(this.traced);
1015
+ if (!all.length)
1016
+ return;
1017
+ const ms = (v) => (Math.round(v * 10) / 10).toString();
1018
+ const part = (th) => {
1019
+ const s = this.traced[th];
1020
+ if (!s)
1021
+ return `${th}: no frames`;
1022
+ const span = Math.max(1, s.last - s.since);
1023
+ return `${th}: ${s.n} frames in ${Math.round(span)} ms, ${Math.round(s.n * 1000 / span)}/s (gap min ${ms(s.min)}, avg ${ms(s.sum / s.n)}, max ${ms(s.max)} ms)`;
1024
+ };
1025
+ this.debug(`on ${this.collector.currentScreen}, ${this.tracedAnims} animation starts. ${part("js")}; ${part("ui")}`);
1026
+ this.traced = {};
1027
+ this.tracedAnims = 0;
1028
+ }
1029
+ traceReport(report) {
1030
+ const frames = {};
1031
+ for (const s of report.screens)
1032
+ for (const [th, st] of Object.entries(s.threads))
1033
+ frames[th] = (frames[th] ?? 0) + (st?.frames ?? 0);
1034
+ const measured = report.session.measuredMs;
1035
+ const cap = Math.ceil(measured * VITALS_MAX_HZ / 1000);
1036
+ const over = Object.entries(frames).filter(([, n]) => n > cap).map(([th]) => th);
1037
+ this.debug(`report ${report.session.seq} (${report.session.ended}): measured ${measured} ms of ${report.session.foregroundMs} ms in the foreground, ` + `so at most about ${cap} frames a thread; js ${frames.js ?? 0}, ui ${frames.ui ?? 0}` + `${over.length ? ` (too many on ${over.join(" and ")}: the server will refuse it)` : ""}; ` + `${report.screens.length} screens, ${report.sites.length} animation sites`);
949
1038
  }
950
1039
  checkWindow(now) {
951
1040
  if (this.collector.foregroundAt(now) >= VITALS_WINDOW_MS)
@@ -971,10 +1060,15 @@ class VitalsController {
971
1060
  }
972
1061
  const storage = this.opts?.storage;
973
1062
  if (!report) {
1063
+ this.debug(`window closed (${ended}) with nothing to report`);
974
1064
  await write(storage, STORAGE_KEYS.checkpoint, null);
975
1065
  return;
976
1066
  }
977
1067
  this.seq++;
1068
+ if (this.opts?.debug) {
1069
+ this.flushTrace();
1070
+ this.traceReport(report);
1071
+ }
978
1072
  await write(storage, STORAGE_KEYS.checkpoint, report);
979
1073
  await this.send(JSON.stringify(report));
980
1074
  await write(storage, STORAGE_KEYS.checkpoint, null);
@@ -1103,6 +1197,7 @@ class VitalsController {
1103
1197
  body
1104
1198
  });
1105
1199
  if (res.status === 200) {
1200
+ this.debug("report accepted");
1106
1201
  const json = await res.json().catch(() => null);
1107
1202
  if (json?.config)
1108
1203
  await this.applyConfig(json.config);
@@ -1131,8 +1226,10 @@ class VitalsController {
1131
1226
  return;
1132
1227
  if (res.status === 200) {
1133
1228
  const config = await res.json();
1134
- if (config && typeof config === "object" && typeof config.sampleRate === "number")
1229
+ if (config && typeof config === "object" && typeof config.sampleRate === "number") {
1230
+ this.debug(`config: sample rate ${config.sampleRate}, development builds ${config.collect?.development ? "collected" : "not collected"}`);
1135
1231
  await this.applyConfig(config);
1232
+ }
1136
1233
  } else if (res.status === 401 || res.status === 410)
1137
1234
  await this.killSwitch();
1138
1235
  } catch {}
@@ -1148,6 +1245,19 @@ class VitalsController {
1148
1245
  }
1149
1246
  if (!config.collect.reduceMotion)
1150
1247
  this.setReduceMotion(false);
1248
+ if (this.opts?.environment === "development")
1249
+ this.developmentConfig(config.collect.development);
1250
+ }
1251
+ developmentConfig(kept) {
1252
+ if (this.toldDevelopment !== kept) {
1253
+ this.toldDevelopment = kept;
1254
+ if (kept)
1255
+ this.debug("this project keeps development builds: this build's reports show in the dashboard's Development view");
1256
+ else
1257
+ this.env.log?.("[keyframer vitals] This project does not keep reports from development builds, so this build sends nothing. " + `To test here, turn on "Development builds" in the project's setup on keyframer.dev, then reload. Release builds are not affected.`, "info");
1258
+ }
1259
+ if (!kept && this.sampled)
1260
+ this.endSampling();
1151
1261
  }
1152
1262
  async killSwitch() {
1153
1263
  this.log("turned off on this device for 7 days by the project");
@@ -1183,6 +1293,10 @@ class VitalsController {
1183
1293
  log(message) {
1184
1294
  this.env.log?.(`[keyframer vitals] ${message}`);
1185
1295
  }
1296
+ debug(message) {
1297
+ if (this.opts?.debug)
1298
+ this.env.log?.(`[keyframer vitals] ${message}`, "debug");
1299
+ }
1186
1300
  }
1187
1301
 
1188
1302
  // src/hook.ts
@@ -1265,6 +1379,49 @@ function tickFieldMeter(f, t) {
1265
1379
  return f.buf.length >= 90;
1266
1380
  }
1267
1381
 
1382
+ // src/jsLoop.ts
1383
+ var MIN_FRAME_GAP_MS = 1000 / 240;
1384
+ function createJsLoop({ raf, later, now, frame }) {
1385
+ let running = false;
1386
+ let gen = 0;
1387
+ let last = -1;
1388
+ const chain = (mine) => {
1389
+ const tick = () => {
1390
+ if (!running || mine !== gen)
1391
+ return;
1392
+ const t = now();
1393
+ if (last >= 0 && t - last < MIN_FRAME_GAP_MS) {
1394
+ later(tick);
1395
+ return;
1396
+ }
1397
+ last = t;
1398
+ if (frame(t))
1399
+ raf(tick);
1400
+ else
1401
+ running = false;
1402
+ };
1403
+ return tick;
1404
+ };
1405
+ return {
1406
+ get running() {
1407
+ return running;
1408
+ },
1409
+ start() {
1410
+ if (running)
1411
+ return false;
1412
+ running = true;
1413
+ gen++;
1414
+ last = -1;
1415
+ raf(chain(gen));
1416
+ return true;
1417
+ },
1418
+ stop() {
1419
+ running = false;
1420
+ gen++;
1421
+ }
1422
+ };
1423
+ }
1424
+
1268
1425
  // src/native.ts
1269
1426
  var perf = globalThis.performance;
1270
1427
  var now = () => perf && typeof perf.now === "function" ? perf.now() : Date.now();
@@ -1289,7 +1446,7 @@ var env = {
1289
1446
  setTimeout: (fn, ms) => setTimeout(fn, ms),
1290
1447
  clearTimeout: (h) => clearTimeout(h),
1291
1448
  platform: { os: Platform.OS === "ios" ? "ios" : "android", osMajor: osMajor() },
1292
- log: (m) => console.warn(m)
1449
+ log: (m, level) => level === "debug" || level === "info" ? console.log(m) : console.warn(m)
1293
1450
  };
1294
1451
  var controller = new VitalsController(env);
1295
1452
  var jsHook = createJsHook(controller, () => checkScreen());
@@ -1405,21 +1562,19 @@ function extendUi(ms) {
1405
1562
  }, ms);
1406
1563
  }
1407
1564
  var jsMeter = createFieldMeter("js");
1408
- var jsRunning = false;
1409
- function jsTick() {
1410
- if (!jsRunning)
1411
- return;
1412
- const t = now();
1413
- const last = jsMeter.m.last;
1414
- const interval = jsMeter.m.interval;
1415
- tickFrameMeter(jsMeter.m, t);
1416
- jsMeter.m.stats.length = 0;
1417
- jsMeter.m.drops.length = 0;
1418
- if (controller.jsFrame(t, last >= 0 ? t - last : 0, interval))
1419
- requestAnimationFrame(jsTick);
1420
- else
1421
- jsRunning = false;
1422
- }
1565
+ var jsLoop = createJsLoop({
1566
+ raf: (cb) => requestAnimationFrame(cb),
1567
+ later: (cb) => setTimeout(cb, 16),
1568
+ now,
1569
+ frame: (t) => {
1570
+ const last = jsMeter.m.last;
1571
+ const interval = jsMeter.m.interval;
1572
+ tickFrameMeter(jsMeter.m, t);
1573
+ jsMeter.m.stats.length = 0;
1574
+ jsMeter.m.drops.length = 0;
1575
+ return controller.jsFrame(t, last >= 0 ? t - last : 0, interval);
1576
+ }
1577
+ });
1423
1578
  var uiInstalled = false;
1424
1579
  var meters = {
1425
1580
  install() {
@@ -1438,10 +1593,9 @@ var meters = {
1438
1593
  uiInstalled = false;
1439
1594
  },
1440
1595
  start() {
1441
- if (!jsRunning) {
1442
- jsRunning = true;
1596
+ if (!jsLoop.running) {
1443
1597
  jsMeter.m.last = -1;
1444
- requestAnimationFrame(jsTick);
1598
+ jsLoop.start();
1445
1599
  }
1446
1600
  },
1447
1601
  extendUi(ms) {
@@ -1449,7 +1603,7 @@ var meters = {
1449
1603
  extendUi(ms);
1450
1604
  },
1451
1605
  stop() {
1452
- jsRunning = false;
1606
+ jsLoop.stop();
1453
1607
  if (uiInstalled)
1454
1608
  stopUi();
1455
1609
  }
@@ -1496,7 +1650,11 @@ async function startVitals(opts) {
1496
1650
  lastRoute = null;
1497
1651
  manualScreen = false;
1498
1652
  const environment = opts.environment ?? (typeof __DEV__ !== "undefined" && __DEV__ ? "development" : "production");
1499
- const appState = AppState.addEventListener("change", (s) => controller.appState(s !== "background"));
1653
+ const appState = AppState.addEventListener("change", (s) => {
1654
+ if (s !== "active")
1655
+ touchDown = false;
1656
+ controller.appState(s !== "background", s === "active");
1657
+ });
1500
1658
  subscriptions.push(() => appState.remove());
1501
1659
  if (opts.navigationRef) {
1502
1660
  const off = opts.navigationRef.addListener("state", checkScreen);
@@ -48,6 +48,12 @@ export type ControllerOptions = {
48
48
  endpoint?: string;
49
49
  /** Bundle id or package name. */
50
50
  appId?: string;
51
+ /**
52
+ * Logs what this device measures and sends, with console.log: the sampling decision, when the frame
53
+ * meters run, frames a second per thread with their gaps, and each report against the server's
54
+ * limits. For development builds; leave it off in production.
55
+ */
56
+ debug?: boolean;
51
57
  };
52
58
  type HttpResponse = {
53
59
  status: number;
@@ -72,7 +78,8 @@ export type ControllerEnv = {
72
78
  };
73
79
  /** Local calendar day of a wall time, `YYYY-MM-DD`. */
74
80
  day?(wall: number): string;
75
- log?(message: string): void;
81
+ /** Warnings (refusals, kill switch), notes for the developer ('info') and, with `debug`, the trace ('debug'). */
82
+ log?(message: string, level?: 'warn' | 'info' | 'debug'): void;
76
83
  };
77
84
  /** The frame meters and the instrumentation hooks, as the platform runs them. */
78
85
  export type Meters = {
@@ -134,6 +141,19 @@ export declare class VitalsController {
134
141
  private meters;
135
142
  private screen;
136
143
  private reduceMotion;
144
+ /**
145
+ * debug: frames per thread, printed about once a second. Each thread keeps its own span (its earliest
146
+ * frame start to its latest end): UI frames reach the JS side in batches of about half a second, so a
147
+ * shared span would halve one thread's rate.
148
+ */
149
+ private traced;
150
+ private tracedAnims;
151
+ /** debug: between "meters on" and "meters off", so each is logged once per motion window. */
152
+ private metering;
153
+ /** False while the app is in the foreground but not active (iOS 'inactive'): nothing is measured. */
154
+ private drawing;
155
+ /** What this launch last told the developer about the project keeping development builds. */
156
+ private toldDevelopment;
137
157
  constructor(env: ControllerEnv);
138
158
  get isSampled(): boolean;
139
159
  get isEnabled(): boolean;
@@ -150,8 +170,11 @@ export declare class VitalsController {
150
170
  stop(drop: boolean): Promise<void>;
151
171
  private beginSession;
152
172
  private endSampling;
153
- /** AppState: 'active' or anything else (background, inactive counts as foreground on iOS). */
154
- appState(active: boolean): void;
173
+ /**
174
+ * AppState. `active`: in the foreground (iOS 'inactive' counts, the app is still on screen). `drawing`:
175
+ * 'active' itself; while it is false the meters stop and motion does not start them (jsLoop.ts).
176
+ */
177
+ appState(active: boolean, drawing?: boolean): void;
155
178
  setScreen(name: string): void;
156
179
  setReduceMotion(on: boolean): void;
157
180
  /** A new id for an animation call on the JS thread (the UI runtime numbers its own from UI_EVENT_BASE). */
@@ -169,6 +192,8 @@ export declare class VitalsController {
169
192
  scroll(t?: number): void;
170
193
  touch(down: boolean, t?: number): void;
171
194
  private motion;
195
+ /** Meters off and the gate shut: measured time stops at `now`; the next motion starts them again. */
196
+ private pauseMeters;
172
197
  private opened;
173
198
  /** The UI loop keeps its own time: tell it how long the gate stays open when that grows. */
174
199
  private keepUi;
@@ -179,6 +204,10 @@ export declare class VitalsController {
179
204
  jsFrame(t: number, dt: number, budget: number): boolean;
180
205
  /** UI-thread frames as `[t, dt, budget, …]`, `t` already on the JS clock. */
181
206
  uiFrames(buf: number[]): void;
207
+ private traceFrame;
208
+ /** One line: frames a second per thread (60, 90 or 120 when the meters are right) and their gaps. */
209
+ private flushTrace;
210
+ private traceReport;
182
211
  private checkWindow;
183
212
  /** The report the open window would send now, or null when it holds nothing. */
184
213
  report(ended: VitalsReport['session']['ended'], now?: number): VitalsReport | null;
@@ -200,10 +229,16 @@ export declare class VitalsController {
200
229
  private fetchConfig;
201
230
  /** A config from the server: cached, and acted on (stop, disabled, a minimum SDK version). */
202
231
  applyConfig(config: VitalsConfig): Promise<void>;
232
+ /**
233
+ * A development build learns whether the project keeps its reports. Off: the developer is told once a
234
+ * launch (the server would drop every report) and measuring stops; on: said under `debug`.
235
+ */
236
+ private developmentConfig;
203
237
  private killSwitch;
204
238
  private allowed;
205
239
  private rate;
206
240
  private clearTimers;
207
241
  private log;
242
+ private debug;
208
243
  }
209
244
  export {};
@@ -0,0 +1,16 @@
1
+ /** The shortest gap between two display frames: 240 Hz, the server's limit (VITALS_MAX_HZ). */
2
+ export declare const MIN_FRAME_GAP_MS: number;
3
+ export type JsLoopDeps = {
4
+ raf: (cb: () => void) => unknown;
5
+ /** Waits about a frame without the display (setTimeout): used when requestAnimationFrame stops waiting for it. */
6
+ later: (cb: () => void) => unknown;
7
+ now: () => number;
8
+ /** One frame that ended at `t`: return false to end the loop (the motion window closed). */
9
+ frame: (t: number) => boolean;
10
+ };
11
+ export declare function createJsLoop({ raf, later, now, frame }: JsLoopDeps): {
12
+ readonly running: boolean;
13
+ /** Starts the loop unless it is already running. Returns true when it started a new chain. */
14
+ start(): boolean;
15
+ stop(): void;
16
+ };
@@ -1,3 +1,3 @@
1
1
  /** This package's version: sent in every report's `sdk`, and compared with the config's `minSdk`. Keep it in step with package.json. */
2
2
  export declare const SDK_NAME = "@keyframerdev/vitals";
3
- export declare const SDK_VERSION = "0.1.1";
3
+ export declare const SDK_VERSION = "0.1.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keyframerdev/vitals",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Motion Vitals for React Native: slow frames and janky animations from production, by screen and call site",
5
5
  "keywords": [
6
6
  "react-native",