@keyframerdev/vitals 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * React Native wiring of the controller: the hooks the Babel plugin's calls run into (on the JS runtime
3
+ * and on the UI runtime), the two frame loops, AppState, navigation and Reduce Motion.
4
+ *
5
+ * Frame loops run only while the motion gate is open (production.md 2.6: nothing scheduled while
6
+ * nothing moves). The UI loop uses the UI runtime's requestAnimationFrame, like the inspector's, keeps
7
+ * its own "until" and stops itself; it hands frames to JS in batches of about a quarter second. The JS
8
+ * loop is requestAnimationFrame on the JS thread and asks the controller on every frame whether to go on.
9
+ */
10
+ import { VitalsController, type ControllerOptions } from './controller';
11
+ /** What startVitals reads from a React Navigation or expo-router container ref. */
12
+ export type NavigationRefLike = {
13
+ isReady?(): boolean;
14
+ getCurrentRoute(): {
15
+ name: string;
16
+ path?: string;
17
+ } | undefined | null;
18
+ addListener(event: 'state', cb: () => void): (() => void) | {
19
+ remove(): void;
20
+ } | void;
21
+ };
22
+ export type VitalsOptions = ControllerOptions & {
23
+ /** A React Navigation `createNavigationContainerRef()` or expo-router `useNavigationContainerRef()`: screens are named from its routes. */
24
+ navigationRef?: NavigationRefLike | null;
25
+ /** Names a route yourself (still masked and cut to 64 characters). Default: the route's name. */
26
+ screenName?: (route: {
27
+ name: string;
28
+ path?: string;
29
+ }) => string;
30
+ };
31
+ export declare const controller: VitalsController;
32
+ /**
33
+ * Starts Motion Vitals. Nothing runs before this call (call it after your own consent flow if your app
34
+ * needs one); from here this session may be sampled and report. Safe to call again with new options.
35
+ */
36
+ export declare function startVitals(opts: VitalsOptions): Promise<void>;
37
+ /** Stops (false: now, and drops what is pending) or starts again (true) with the options of startVitals. */
38
+ export declare function setVitalsEnabled(enabled: boolean): Promise<void>;
39
+ /** Names the current screen where navigation is not automatic (no navigationRef, or a screen inside one). */
40
+ export declare function setVitalsScreen(name: string): void;
41
+ export declare function vitalsTouch(down: boolean, activeTouches: number): void;
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Motion Vitals: the wire format between the production reporters and the ingest Worker.
3
+ *
4
+ * Reporters: `@keyframerdev/vitals` (React Native), `KeyframerVitals` (iOS), `dev.keyframer:vitals`
5
+ * (Android), `keyframer_vitals` (Flutter). Ingest: `https://vitals.keyframer.dev` (Keyframer's
6
+ * `apps/vitals`). Symbols: produced by `@keyframerdev/vitals-cli`, read by the ingest Worker.
7
+ *
8
+ * Import-free, like inspector.ts, so every SDK can mirror it. Design: `inspector/docs/production.md`;
9
+ * build plan and ownership: `inspector/docs/vitals-build.md`. Golden messages every SDK's tests check
10
+ * against: `inspector/packages/protocol/fixtures/vitals/`.
11
+ */
12
+ export declare const VITALS_FORMAT: "keyframer-vitals";
13
+ export declare const VITALS_VERSION: 1;
14
+ export declare const VITALS_ENDPOINT = "https://vitals.keyframer.dev";
15
+ /**
16
+ * Histogram buckets of frames missed per frame, by lower bound: 0, 1, 2, 3-4, 5-8, 9-16, 17-32, 33+.
17
+ * The same eight on every platform, so 60 and 120 Hz devices add up.
18
+ */
19
+ export declare const VITALS_BUCKETS: readonly [0, 1, 2, 3, 5, 9, 17, 33];
20
+ /** A frame this long or longer is frozen, in absolute time (Android vitals' definition). */
21
+ export declare const VITALS_FROZEN_MS = 700;
22
+ /** A session window is at most this much foreground time; a longer session sends several windows. */
23
+ export declare const VITALS_WINDOW_MS: number;
24
+ /** A session ends after this long in the background. */
25
+ export declare const VITALS_SESSION_GAP_MS: number;
26
+ /** Hard caps per report. Ingest refuses a report over any of them (413 for bytes, 400 otherwise). */
27
+ export declare const VITALS_LIMITS: {
28
+ readonly screens: 40;
29
+ readonly sites: 200;
30
+ readonly worst: 10;
31
+ readonly images: 64;
32
+ readonly bytes: number;
33
+ readonly screenName: 64;
34
+ };
35
+ /** At most this many reported sessions per device per day (counted on the device, never sent). */
36
+ export declare const VITALS_MAX_SESSIONS_PER_DAY = 3;
37
+ /** Default share of sessions that report. The project's config can lower or raise it. */
38
+ export declare const VITALS_DEFAULT_SAMPLE_RATE = 0.1;
39
+ export type VitalsPlatform = 'react-native' | 'ios' | 'android' | 'flutter';
40
+ export type VitalsOs = 'ios' | 'android';
41
+ /** Same roles as InspectorThread: `js` is the app thread (JS, main, UI isolate), `ui` the frame thread (RN UI, Android RenderThread, Flutter raster). */
42
+ export type VitalsThread = 'ui' | 'js';
43
+ export type VitalsTier = 'low' | 'mid' | 'high';
44
+ export type VitalsThermal = 'nominal' | 'fair' | 'serious' | 'critical';
45
+ /**
46
+ * Why one of a window's worst frames was slow, computed on the device (production.md 1, "Attribution").
47
+ * `js-task`, `ui-work` and `slow-render` need deep mode and are not sent in version 1.
48
+ */
49
+ export type VitalsCause = 'screen-mount' | 'burst' | 'busy-start' | 'scroll' | 'gesture' | 'js-task' | 'ui-work' | 'slow-render' | 'unknown';
50
+ /** Frame counters of one thread on one screen. `hist` has VITALS_BUCKETS.length entries. */
51
+ export type VitalsThreadStats = {
52
+ frames: number;
53
+ hist: number[];
54
+ frozen: number;
55
+ /** Sum of max(0, frameMs - budget). Divided by measured seconds it is Apple's hitch time ratio. */
56
+ hitchMs: number;
57
+ /** Frames and missed frames while no tracked animation ran: the screen's baseline. */
58
+ idleFrames: number;
59
+ idleMissed: number;
60
+ };
61
+ /**
62
+ * Where a call site is in the app, and what the server needs to name it.
63
+ * - `source`: exact file and line (React Native's Babel plugin; Flutter builds that keep positions).
64
+ * - `hashed`: React Native with `hashSites`; named from the uploaded `rn-sites` manifest.
65
+ * - `name`: a name the developer gave (`trace("card-expand")`, `kfTrack`, `KfAnimationController(name:)`), plus a type name when the build keeps it.
66
+ * - `native`: an instruction offset in `images[image]` (iOS, Flutter AOT); named from an uploaded `macho` or `elf` symbol file.
67
+ * SDKs send the offset of the return address they captured, as is; the server looks up `offset - 1`
68
+ * (the call instruction). Flutter on iOS is a `macho` image (App.framework/App, LC_UUID = the Dart build id); on Android an `elf` one (libapp.so).
69
+ * - `jvm`: an Android stack frame, possibly renamed by R8; named from an uploaded `r8` mapping.
70
+ */
71
+ export type VitalsSiteRef = {
72
+ kind: 'source';
73
+ file: string;
74
+ line: number;
75
+ column?: number;
76
+ } | {
77
+ kind: 'hashed';
78
+ hash: string;
79
+ } | {
80
+ kind: 'name';
81
+ name: string;
82
+ type?: string;
83
+ } | {
84
+ kind: 'native';
85
+ image: number;
86
+ offset: number;
87
+ type?: string;
88
+ } | {
89
+ kind: 'jvm';
90
+ className: string;
91
+ method: string;
92
+ line?: number;
93
+ file?: string;
94
+ };
95
+ export type VitalsSite = {
96
+ ref: VitalsSiteRef;
97
+ /**
98
+ * Stable across versions when the SDK can tell: the site without line or column
99
+ * (`app/feed.tsx#FeedCard#withSpring#scale#1`, the ordinal telling identical calls apart), or the
100
+ * developer's name. Omitted for `native` and `jvm` refs: the server derives it after symbolication.
101
+ */
102
+ key?: string;
103
+ /** The animation API: withSpring, UIView.animate, animateFloatAsState, AnimationController.forward… */
104
+ fn: string;
105
+ component?: string | null;
106
+ /** The animated property or target when known: scale, translateY, alpha… */
107
+ prop?: string | null;
108
+ /** Index into `screens`: where it ran most in this window. */
109
+ screen: number;
110
+ runs: number;
111
+ activeMs: number;
112
+ /** Frames drawn while it ran, and frames missed while it ran. */
113
+ frames: number;
114
+ missed: number;
115
+ hitchMs: number;
116
+ worstMs: number;
117
+ /** Runs that started while Reduce Motion was on. Only when the project collects Reduce Motion. */
118
+ runsReduceMotion?: number;
119
+ };
120
+ export type VitalsWorstFrame = {
121
+ thread: VitalsThread;
122
+ ms: number;
123
+ missed: number;
124
+ screen: number;
125
+ /** Indexes into `sites`: what was running in that frame. */
126
+ sites: number[];
127
+ cause: VitalsCause;
128
+ /** For `screen-mount`: how long after the navigation the frame ended. */
129
+ afterNavMs?: number;
130
+ };
131
+ /** A binary image the report's `native` refs point into: a Mach-O UUID or an ELF build id. */
132
+ export type VitalsImage = {
133
+ /** Lowercase hex without dashes. */
134
+ debugId: string;
135
+ kind: 'macho' | 'elf';
136
+ /** File name only (`Acme`, `App.framework/App`, `libapp.so`), never a path with a user name in it. */
137
+ name: string;
138
+ arch?: string;
139
+ };
140
+ export type VitalsReport = {
141
+ format: typeof VITALS_FORMAT;
142
+ version: typeof VITALS_VERSION;
143
+ app: {
144
+ /** Bundle id or package name. Lets the server warn when a key is used by another app. */
145
+ id?: string;
146
+ version: string;
147
+ build?: string;
148
+ platform: VitalsPlatform;
149
+ /** Free-form framework label for the dashboard: "SwiftUI", "Compose", "Views"… */
150
+ framework?: string;
151
+ environment: 'production' | 'development';
152
+ /** Android: the build id the Gradle plugin injects, matching the uploaded R8 mapping. */
153
+ debugId?: string;
154
+ };
155
+ sdk: {
156
+ name: string;
157
+ version: string;
158
+ };
159
+ device: {
160
+ os: VitalsOs;
161
+ osMajor: number;
162
+ /** Raw model identifier (iPhone15,3, SM-A146B) unless the project turns models off; the server maps it to a name and tier. */
163
+ model?: string;
164
+ tier?: VitalsTier;
165
+ memoryGb?: number;
166
+ };
167
+ display: {
168
+ hz: number;
169
+ adaptive?: boolean;
170
+ targetHz: number;
171
+ };
172
+ /** Any time true in this window. `reduceMotion` only when the project collects it. */
173
+ state: {
174
+ reduceMotion?: boolean;
175
+ lowPower?: boolean;
176
+ thermal?: VitalsThermal;
177
+ };
178
+ session: {
179
+ /** Random per session, in memory only, never written to disk or reused. Links the windows of one session. */
180
+ nonce: string;
181
+ /** 0 for the first window of a session. Only seq 0 counts toward usage. */
182
+ seq: number;
183
+ sampleRate: number;
184
+ foregroundMs: number;
185
+ measuredMs: number;
186
+ ended: 'background' | 'window' | 'unknown';
187
+ };
188
+ screens: Array<{
189
+ name: string;
190
+ ms: number;
191
+ threads: Partial<Record<VitalsThread, VitalsThreadStats>>;
192
+ }>;
193
+ sites: VitalsSite[];
194
+ /** The longest frames of the window, at most VITALS_LIMITS.worst (SDKs send 5). */
195
+ worst: VitalsWorstFrame[];
196
+ images?: VitalsImage[];
197
+ };
198
+ /** Per-project settings every SDK reads: from upload responses and from GET /v1/config/<field key>. */
199
+ export type VitalsConfig = {
200
+ enabled: boolean;
201
+ /** The rate to use now: base x the allowance factor x the release boost, already combined. */
202
+ sampleRate: number;
203
+ /** ISO time. While boosted, sampleRate already includes the boost. */
204
+ boostUntil?: string;
205
+ /** SDK versions below these stop reporting (a buggy release in the wild). */
206
+ minSdk?: Partial<Record<VitalsPlatform, string>>;
207
+ collect: {
208
+ reduceMotion: boolean;
209
+ deviceModel: boolean;
210
+ development: boolean;
211
+ };
212
+ /** true: turn off on this device for 7 days. */
213
+ stop?: boolean;
214
+ /** How long a device may keep this config before fetching again. */
215
+ ttlSec: number;
216
+ };
217
+ /**
218
+ * POST /v1/reports, `Authorization: Bearer kfp_…`, JSON body (gzip with `Content-Encoding: gzip`
219
+ * where the platform can). 200 answers this; 400 invalid; 401 unknown or revoked key and 410 deleted
220
+ * project (the SDK stops for 7 days); 413 too large; 429 rate limited (retry later).
221
+ */
222
+ export type VitalsIngestResponse = {
223
+ ok: boolean;
224
+ config: VitalsConfig;
225
+ error?: string;
226
+ };
227
+ /** What a symbol upload is for. */
228
+ export type VitalsSymbolKind = 'macho' | 'elf' | 'r8' | 'rn-sites';
229
+ /**
230
+ * Symbols are extracted on the developer's machine or CI by `keyframer-vitals symbols upload`, never on
231
+ * the server: a manifest plus chunks, so the Worker reads one small chunk per lookup.
232
+ *
233
+ * Upload: PUT /v1/symbols/<kind>/<debugId>/<chunk file> (gzipped JSON body) for each chunk, then
234
+ * POST /v1/symbols/<kind>/<debugId>/manifest with the manifest; both with `Authorization: Bearer kft_…`.
235
+ * The manifest is written last, so a symbol set is visible only when complete.
236
+ */
237
+ export type VitalsSymbolManifest = {
238
+ format: 'keyframer-vitals-symbols';
239
+ version: 1;
240
+ kind: VitalsSymbolKind;
241
+ /**
242
+ * macho: LC_UUID; elf: GNU build id (both lowercase hex, no dashes); r8: the Gradle plugin's build id,
243
+ * else `<app version>+<build>`; rn-sites: `<app version>+<build>`.
244
+ */
245
+ debugId: string;
246
+ name?: string;
247
+ arch?: string;
248
+ app?: {
249
+ id?: string;
250
+ version?: string;
251
+ build?: string;
252
+ };
253
+ /**
254
+ * Name schemes left mangled in the chunks: `swift` when the upload ran without `swift-demangle`
255
+ * (not on macOS). Absent when every name is demangled.
256
+ */
257
+ mangled?: Array<'swift'>;
258
+ /**
259
+ * Sorted by `first`: the lowest offset or key in each chunk. macho/elf: the offset as 16 lowercase
260
+ * hex digits (string order is numeric order); r8: the obfuscated class name; rn-sites: the hash.
261
+ * A lookup reads only the last chunk whose `first` is at most the offset or key (code point order).
262
+ * Chunk files are named `0000.json`, `0001.json`, ….
263
+ */
264
+ chunks: Array<{
265
+ file: string;
266
+ first: string;
267
+ }>;
268
+ createdAt: string;
269
+ cli: {
270
+ name: string;
271
+ version: string;
272
+ };
273
+ };
274
+ /**
275
+ * macho and elf chunks. Offsets are relative to the image's load address (the __TEXT segment's vmaddr
276
+ * for Mach-O, the first PT_LOAD for ELF), which is what SDKs send in `native` refs.
277
+ */
278
+ export type VitalsSymbolChunk = {
279
+ names: string[];
280
+ files: string[];
281
+ /** Functions sorted by start: [start, end, name index]. Demangled. */
282
+ fns: Array<[start: number, end: number, name: number]>;
283
+ /**
284
+ * The line table, sorted by start: [start, file index, line]. Consecutive equal rows merged. Line 0:
285
+ * no line information from that offset on. A chunk that starts inside a function or a row repeats
286
+ * them (the function in `fns`, the row at the chunk's first offset), so one chunk answers.
287
+ */
288
+ lines: Array<[start: number, file: number, line: number]>;
289
+ };
290
+ /**
291
+ * r8 chunks: obfuscated class name to the original class, and its methods' line ranges. A line L in
292
+ * obfStart..obfEnd resolves to `origStart` when `origEnd` equals it, else to `origStart + (L - obfStart)`
293
+ * (`origEnd` absent: linear). All four 0: no line information. Rows sharing an obfuscated name and range
294
+ * are an inline stack, innermost first. `original` is the method name, prefixed with its class when it
295
+ * was inlined from another class (`com.acme.SpringsKt.cardScale`). `file`: the R8 source file name.
296
+ */
297
+ export type VitalsR8Chunk = {
298
+ classes: Record<string, {
299
+ name: string;
300
+ file?: string;
301
+ methods: Array<[obfuscated: string, original: string, obfStart: number, obfEnd: number, origStart: number, origEnd?: number]>;
302
+ }>;
303
+ };
304
+ /** rn-sites chunks: a hashed site id to the source site it stands for. */
305
+ export type VitalsSitesChunk = {
306
+ sites: Record<string, {
307
+ file: string;
308
+ line: number;
309
+ column?: number;
310
+ fn: string;
311
+ component?: string | null;
312
+ prop?: string | null;
313
+ key: string;
314
+ }>;
315
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * What the Babel plugin's rewritten calls run into (`@keyframerdev/vitals/babel`). Until `startVitals`
3
+ * installs a hook, and after `setVitalsEnabled(false)`, each one is a straight pass-through: one global
4
+ * read, then the original call, so an instrumented app behaves exactly like the original.
5
+ *
6
+ * `__kfvAnim` and `__kfvScroll` are worklets: animation calls are often made inside gesture callbacks,
7
+ * reactions and scroll handlers, which run on the UI runtime, where the hook is a second one installed
8
+ * there (native.ts). Nothing here reads an argument's value: the hook keeps only what an animation's
9
+ * length is estimated from.
10
+ */
11
+ import type { SiteInfo } from './collector';
12
+ type AnimFn = (...args: unknown[]) => unknown;
13
+ export type VitalsHook = {
14
+ /** Runs the animation call (through `via`, the inspector's `__kfAnim`, when both plugins ran) and notes its start. */
15
+ anim(fn: AnimFn, site: SiteInfo, args: unknown[], via: AnimFn | null, viaSite: unknown): unknown;
16
+ scroll(): void;
17
+ /** JS runtime only: a layout animation (entering, exiting, layout) started. */
18
+ layout?(site: SiteInfo, kind: LayoutKind, builder: unknown): void;
19
+ };
20
+ export type LayoutKind = 'entering' | 'exiting' | 'layout';
21
+ /** `withSpring(1, cfg)` → `__kfvAnim(withSpring, site, 1, cfg)`. */
22
+ export declare function __kfvAnim(fn: AnimFn, site: SiteInfo, ...args: unknown[]): unknown;
23
+ /**
24
+ * The same, for a call the inspector's development plugin wrapped first:
25
+ * `__kfAnim(withSpring, isite, 1, cfg)` → `__kfvAnimVia(__kfAnim, site, withSpring, isite, 1, cfg)`.
26
+ */
27
+ export declare function __kfvAnimVia(via: AnimFn, site: SiteInfo, fn: AnimFn, viaSite: unknown, ...args: unknown[]): unknown;
28
+ /** First statement of every useAnimatedScrollHandler worklet: a scroll keeps the meters running. */
29
+ export declare function __kfvScroll(): void;
30
+ /** A plain JS `onScroll` handler: same, then the app's handler. Anything that is not a function goes through untouched. */
31
+ export declare function __kfvScrollJs<F>(fn: F): F;
32
+ /** An entering / exiting / layout prop: returned as it is, remembered for its element. */
33
+ export declare function __kfvLayout<B>(builder: B, kind: LayoutKind, site: SiteInfo): B;
34
+ /**
35
+ * The ref of an element with `entering` or `exiting`: the entering animation starts when a new view
36
+ * mounts, the exiting one when a view goes; detaching and re-attaching the same view in one commit (a
37
+ * ref that changes every render) is neither. The app's own ref gets the view as before.
38
+ */
39
+ export declare function __kfvMotionRef(entering: SiteInfo | null, exiting: SiteInfo | null, prev?: unknown): (instance: unknown) => unknown;
40
+ /** The onLayout of an element with `layout`: every layout after a view's first is a layout animation. */
41
+ export declare function __kfvLayoutMoved(site: SiteInfo, prev?: unknown): (e: unknown) => void;
42
+ export {};
@@ -0,0 +1,3 @@
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
+ export declare const SDK_NAME = "@keyframerdev/vitals";
3
+ export declare const SDK_VERSION = "0.1.0";
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@keyframerdev/vitals",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Motion Vitals for React Native: slow frames and janky animations from production, by screen and call site",
5
+ "keywords": [
6
+ "react-native",
7
+ "reanimated",
8
+ "expo",
9
+ "performance",
10
+ "animation",
11
+ "frame-drops",
12
+ "jank",
13
+ "monitoring",
14
+ "keyframer"
15
+ ],
16
+ "homepage": "https://keyframer.dev/inspector/docs/vitals/react-native",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/keyframer-dev/inspector.git",
20
+ "directory": "react-native-vitals"
21
+ },
22
+ "bugs": "https://github.com/keyframer-dev/inspector/issues",
23
+ "license": "MIT",
24
+ "author": "Catalin Miron",
25
+ "type": "module",
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/types/index.d.ts",
28
+ "react-native": "./dist/index.js",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/types/index.d.ts",
32
+ "react-native": "./dist/index.js",
33
+ "default": "./dist/index.js"
34
+ },
35
+ "./runtime": {
36
+ "types": "./dist/types/runtime.d.ts",
37
+ "react-native": "./dist/runtime.js",
38
+ "default": "./dist/runtime.js"
39
+ },
40
+ "./babel": {
41
+ "require": "./dist/babel.cjs",
42
+ "default": "./dist/babel.cjs"
43
+ },
44
+ "./package.json": "./package.json"
45
+ },
46
+ "files": [
47
+ "dist",
48
+ "README.md",
49
+ "LICENSE"
50
+ ],
51
+ "peerDependencies": {
52
+ "react": ">=19.0.0",
53
+ "react-native": ">=0.78.0",
54
+ "react-native-reanimated": ">=4.1.0",
55
+ "react-native-worklets": ">=0.5.0"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "react-native-reanimated": {
59
+ "optional": true
60
+ }
61
+ }
62
+ }