react-native-nitro-tracing 0.6.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.
Files changed (233) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/NitroTracing.podspec +32 -0
  4. package/README.md +75 -0
  5. package/android/CMakeLists.txt +49 -0
  6. package/android/build.gradle +133 -0
  7. package/android/fix-prefab.gradle +51 -0
  8. package/android/gradle.properties +5 -0
  9. package/android/proguard-rules.pro +5 -0
  10. package/android/src/main/AndroidManifest.xml +2 -0
  11. package/android/src/main/cpp/cpp-adapter.cpp +47 -0
  12. package/android/src/main/java/com/margelo/nitro/nitrotracing/HermesSamplingGuard.kt +43 -0
  13. package/android/src/main/java/com/margelo/nitro/nitrotracing/NitroTracingFrameSource.kt +36 -0
  14. package/android/src/main/java/com/margelo/nitro/nitrotracing/NitroTracingPackage.kt +31 -0
  15. package/client.ts +15 -0
  16. package/cpp/HermesSampling.cpp +26 -0
  17. package/cpp/HermesSampling.hpp +10 -0
  18. package/cpp/HybridRecording.cpp +66 -0
  19. package/cpp/HybridRecording.hpp +30 -0
  20. package/cpp/HybridTraceSpan.cpp +10 -0
  21. package/cpp/HybridTraceSpan.hpp +17 -0
  22. package/cpp/HybridTracing.cpp +10 -0
  23. package/cpp/HybridTracing.hpp +10 -0
  24. package/cpp/NativeConversions.cpp +77 -0
  25. package/cpp/NativeConversions.hpp +15 -0
  26. package/cpp/core/NativeSampler.cpp +152 -0
  27. package/cpp/core/NativeSampler.hpp +56 -0
  28. package/cpp/core/Recorder.cpp +218 -0
  29. package/cpp/core/Recorder.hpp +60 -0
  30. package/cpp/core/Retention.cpp +26 -0
  31. package/cpp/core/Retention.hpp +7 -0
  32. package/cpp/core/TraceJson.cpp +214 -0
  33. package/cpp/core/TraceJson.hpp +7 -0
  34. package/cpp/core/TraceTypes.hpp +62 -0
  35. package/expo.ts +2 -0
  36. package/ios/NitroTracingFrameSource.mm +35 -0
  37. package/lib/commonjs/client.cjs +767 -0
  38. package/lib/commonjs/client.cjs.map +7 -0
  39. package/lib/commonjs/expo.cjs +88 -0
  40. package/lib/commonjs/expo.cjs.map +7 -0
  41. package/lib/commonjs/index.cjs +28 -0
  42. package/lib/commonjs/index.cjs.map +7 -0
  43. package/lib/commonjs/performance.cjs +112 -0
  44. package/lib/commonjs/performance.cjs.map +7 -0
  45. package/lib/commonjs/plugins.cjs +477 -0
  46. package/lib/commonjs/plugins.cjs.map +7 -0
  47. package/lib/commonjs/react.cjs +3833 -0
  48. package/lib/commonjs/react.cjs.map +7 -0
  49. package/lib/commonjs/release-profiler.cjs +142 -0
  50. package/lib/commonjs/release-profiler.cjs.map +7 -0
  51. package/lib/commonjs/sentry.cjs +188 -0
  52. package/lib/commonjs/sentry.cjs.map +7 -0
  53. package/lib/module/client.mjs +768 -0
  54. package/lib/module/client.mjs.map +7 -0
  55. package/lib/module/expo.mjs +62 -0
  56. package/lib/module/expo.mjs.map +7 -0
  57. package/lib/module/index.mjs +7 -0
  58. package/lib/module/index.mjs.map +7 -0
  59. package/lib/module/performance.mjs +89 -0
  60. package/lib/module/performance.mjs.map +7 -0
  61. package/lib/module/plugins.mjs +461 -0
  62. package/lib/module/plugins.mjs.map +7 -0
  63. package/lib/module/react.mjs +3851 -0
  64. package/lib/module/react.mjs.map +7 -0
  65. package/lib/module/release-profiler.mjs +137 -0
  66. package/lib/module/release-profiler.mjs.map +7 -0
  67. package/lib/module/sentry.mjs +165 -0
  68. package/lib/module/sentry.mjs.map +7 -0
  69. package/lib/typescript/client.d.ts +4 -0
  70. package/lib/typescript/expo.d.ts +2 -0
  71. package/lib/typescript/performance.d.ts +2 -0
  72. package/lib/typescript/plugins.d.ts +12 -0
  73. package/lib/typescript/react.d.ts +5 -0
  74. package/lib/typescript/release-profiler.d.ts +2 -0
  75. package/lib/typescript/sentry.d.ts +2 -0
  76. package/lib/typescript/src/client/TracingSink.d.ts +10 -0
  77. package/lib/typescript/src/client/createTraceClient.d.ts +82 -0
  78. package/lib/typescript/src/client/playground.d.ts +12 -0
  79. package/lib/typescript/src/expo/ExpoAdapter.d.ts +15 -0
  80. package/lib/typescript/src/expo/index.d.ts +2 -0
  81. package/lib/typescript/src/index.d.ts +6 -0
  82. package/lib/typescript/src/plugins/TracePlugin.d.ts +22 -0
  83. package/lib/typescript/src/plugins/errors.d.ts +15 -0
  84. package/lib/typescript/src/plugins/nativeMetrics.d.ts +13 -0
  85. package/lib/typescript/src/plugins/navigation.d.ts +29 -0
  86. package/lib/typescript/src/plugins/network.d.ts +23 -0
  87. package/lib/typescript/src/plugins/orderSpans.d.ts +3 -0
  88. package/lib/typescript/src/plugins/performance.d.ts +13 -0
  89. package/lib/typescript/src/plugins/releaseProfiler.d.ts +25 -0
  90. package/lib/typescript/src/plugins/runtimeMetrics.d.ts +17 -0
  91. package/lib/typescript/src/plugins/sentry.d.ts +15 -0
  92. package/lib/typescript/src/plugins/startPlugins.d.ts +4 -0
  93. package/lib/typescript/src/react/DetailView.d.ts +11 -0
  94. package/lib/typescript/src/react/ExplorerFilters.d.ts +9 -0
  95. package/lib/typescript/src/react/ExplorerView.d.ts +9 -0
  96. package/lib/typescript/src/react/InspectorControls.d.ts +229 -0
  97. package/lib/typescript/src/react/InspectorHeader.d.ts +8 -0
  98. package/lib/typescript/src/react/InspectorSheet.d.ts +16 -0
  99. package/lib/typescript/src/react/MetricsView.d.ts +7 -0
  100. package/lib/typescript/src/react/SummaryView.d.ts +11 -0
  101. package/lib/typescript/src/react/TimelineView.d.ts +8 -0
  102. package/lib/typescript/src/react/TraceInspector.d.ts +11 -0
  103. package/lib/typescript/src/react/TraceOverlay.d.ts +17 -0
  104. package/lib/typescript/src/react/explorerModel.d.ts +38 -0
  105. package/lib/typescript/src/react/labels.d.ts +203 -0
  106. package/lib/typescript/src/react/native.d.ts +42 -0
  107. package/lib/typescript/src/react/readSnapshot.d.ts +4 -0
  108. package/lib/typescript/src/react/store.d.ts +9 -0
  109. package/lib/typescript/src/react/topics.d.ts +80 -0
  110. package/lib/typescript/src/react/useAppReadyMetric.d.ts +3 -0
  111. package/lib/typescript/src/react/useLiveRecording.d.ts +4 -0
  112. package/lib/typescript/src/react/useTraceViewer.d.ts +140 -0
  113. package/lib/typescript/src/react/viewerModel.d.ts +59 -0
  114. package/lib/typescript/src/specs/Recording.nitro.d.ts +31 -0
  115. package/lib/typescript/src/specs/TraceSpan.nitro.d.ts +14 -0
  116. package/lib/typescript/src/specs/Tracing.nitro.d.ts +17 -0
  117. package/lib/typescript/src/types/CompletedSpanOptions.d.ts +15 -0
  118. package/lib/typescript/src/types/EventContext.d.ts +8 -0
  119. package/lib/typescript/src/types/MarkEvent.d.ts +4 -0
  120. package/lib/typescript/src/types/MarkOptions.d.ts +6 -0
  121. package/lib/typescript/src/types/MetricEvent.d.ts +8 -0
  122. package/lib/typescript/src/types/MetricOptions.d.ts +10 -0
  123. package/lib/typescript/src/types/NativeSamplingOptions.d.ts +7 -0
  124. package/lib/typescript/src/types/ReadOptions.d.ts +7 -0
  125. package/lib/typescript/src/types/RecordingOptions.d.ts +11 -0
  126. package/lib/typescript/src/types/RecordingStats.d.ts +25 -0
  127. package/lib/typescript/src/types/SpanEvent.d.ts +13 -0
  128. package/lib/typescript/src/types/SpanOptions.d.ts +6 -0
  129. package/lib/typescript/src/types/SpanOutcome.d.ts +2 -0
  130. package/lib/typescript/src/types/TraceAttribute.d.ts +7 -0
  131. package/lib/typescript/src/types/TraceAttributes.d.ts +3 -0
  132. package/lib/typescript/src/types/TraceContext.d.ts +10 -0
  133. package/lib/typescript/src/types/TracePage.d.ts +18 -0
  134. package/lib/typescript/src/types.d.ts +17 -0
  135. package/nitro.json +20 -0
  136. package/nitrogen/generated/.gitattributes +1 -0
  137. package/nitrogen/generated/android/NitroTracing+autolinking.cmake +83 -0
  138. package/nitrogen/generated/android/NitroTracing+autolinking.gradle +27 -0
  139. package/nitrogen/generated/android/NitroTracingOnLoad.cpp +49 -0
  140. package/nitrogen/generated/android/NitroTracingOnLoad.hpp +34 -0
  141. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrotracing/NitroTracingOnLoad.kt +35 -0
  142. package/nitrogen/generated/ios/NitroTracing+autolinking.rb +62 -0
  143. package/nitrogen/generated/ios/NitroTracing-Swift-Cxx-Bridge.cpp +17 -0
  144. package/nitrogen/generated/ios/NitroTracing-Swift-Cxx-Bridge.hpp +27 -0
  145. package/nitrogen/generated/ios/NitroTracing-Swift-Cxx-Umbrella.hpp +38 -0
  146. package/nitrogen/generated/ios/NitroTracingAutolinking.mm +35 -0
  147. package/nitrogen/generated/ios/NitroTracingAutolinking.swift +16 -0
  148. package/nitrogen/generated/shared/c++/CompletedSpanOptions.hpp +122 -0
  149. package/nitrogen/generated/shared/c++/HybridRecordingSpec.cpp +31 -0
  150. package/nitrogen/generated/shared/c++/HybridRecordingSpec.hpp +100 -0
  151. package/nitrogen/generated/shared/c++/HybridTraceSpanSpec.cpp +23 -0
  152. package/nitrogen/generated/shared/c++/HybridTraceSpanSpec.hpp +65 -0
  153. package/nitrogen/generated/shared/c++/HybridTracingSpec.cpp +22 -0
  154. package/nitrogen/generated/shared/c++/HybridTracingSpec.hpp +68 -0
  155. package/nitrogen/generated/shared/c++/MarkEvent.hpp +102 -0
  156. package/nitrogen/generated/shared/c++/MarkOptions.hpp +99 -0
  157. package/nitrogen/generated/shared/c++/MetricEvent.hpp +110 -0
  158. package/nitrogen/generated/shared/c++/MetricOptions.hpp +107 -0
  159. package/nitrogen/generated/shared/c++/NativeSamplingOptions.hpp +87 -0
  160. package/nitrogen/generated/shared/c++/ReadOptions.hpp +87 -0
  161. package/nitrogen/generated/shared/c++/RecordingOptions.hpp +95 -0
  162. package/nitrogen/generated/shared/c++/RecordingStats.hpp +123 -0
  163. package/nitrogen/generated/shared/c++/SpanEvent.hpp +121 -0
  164. package/nitrogen/generated/shared/c++/SpanOptions.hpp +99 -0
  165. package/nitrogen/generated/shared/c++/SpanOutcome.hpp +84 -0
  166. package/nitrogen/generated/shared/c++/TraceAttribute.hpp +87 -0
  167. package/nitrogen/generated/shared/c++/TracePage.hpp +111 -0
  168. package/package.json +236 -0
  169. package/performance.ts +2 -0
  170. package/plugins.ts +24 -0
  171. package/react-native.config.js +16 -0
  172. package/react.ts +10 -0
  173. package/release-profiler.ts +6 -0
  174. package/sentry.ts +2 -0
  175. package/src/client/TracingSink.ts +25 -0
  176. package/src/client/createTraceClient.ts +439 -0
  177. package/src/client/playground.ts +143 -0
  178. package/src/expo/ExpoAdapter.ts +71 -0
  179. package/src/expo/index.ts +2 -0
  180. package/src/index.ts +8 -0
  181. package/src/plugins/TracePlugin.ts +22 -0
  182. package/src/plugins/errors.ts +69 -0
  183. package/src/plugins/nativeMetrics.ts +26 -0
  184. package/src/plugins/navigation.ts +111 -0
  185. package/src/plugins/network.ts +178 -0
  186. package/src/plugins/orderSpans.ts +22 -0
  187. package/src/plugins/performance.ts +120 -0
  188. package/src/plugins/releaseProfiler.ts +123 -0
  189. package/src/plugins/runtimeMetrics.ts +131 -0
  190. package/src/plugins/sentry.ts +176 -0
  191. package/src/plugins/startPlugins.ts +58 -0
  192. package/src/react/DetailView.tsx +514 -0
  193. package/src/react/ExplorerFilters.tsx +151 -0
  194. package/src/react/ExplorerView.tsx +243 -0
  195. package/src/react/InspectorControls.tsx +651 -0
  196. package/src/react/InspectorHeader.tsx +171 -0
  197. package/src/react/InspectorSheet.tsx +89 -0
  198. package/src/react/MetricsView.tsx +182 -0
  199. package/src/react/SummaryView.tsx +121 -0
  200. package/src/react/TimelineView.tsx +87 -0
  201. package/src/react/TraceInspector.tsx +196 -0
  202. package/src/react/TraceOverlay.tsx +585 -0
  203. package/src/react/explorerModel.ts +285 -0
  204. package/src/react/labels.ts +234 -0
  205. package/src/react/native.tsx +159 -0
  206. package/src/react/readSnapshot.ts +58 -0
  207. package/src/react/store.ts +31 -0
  208. package/src/react/topics.ts +427 -0
  209. package/src/react/useAppReadyMetric.ts +10 -0
  210. package/src/react/useLiveRecording.ts +43 -0
  211. package/src/react/useTraceViewer.ts +333 -0
  212. package/src/react/viewerModel.ts +172 -0
  213. package/src/specs/Recording.nitro.ts +40 -0
  214. package/src/specs/TraceSpan.nitro.ts +14 -0
  215. package/src/specs/Tracing.nitro.ts +14 -0
  216. package/src/types/CompletedSpanOptions.ts +15 -0
  217. package/src/types/EventContext.ts +8 -0
  218. package/src/types/MarkEvent.ts +3 -0
  219. package/src/types/MarkOptions.ts +6 -0
  220. package/src/types/MetricEvent.ts +8 -0
  221. package/src/types/MetricOptions.ts +10 -0
  222. package/src/types/NativeSamplingOptions.ts +7 -0
  223. package/src/types/ReadOptions.ts +7 -0
  224. package/src/types/RecordingOptions.ts +11 -0
  225. package/src/types/RecordingStats.ts +25 -0
  226. package/src/types/SpanEvent.ts +13 -0
  227. package/src/types/SpanOptions.ts +6 -0
  228. package/src/types/SpanOutcome.ts +2 -0
  229. package/src/types/TraceAttribute.ts +7 -0
  230. package/src/types/TraceAttributes.ts +4 -0
  231. package/src/types/TraceContext.ts +10 -0
  232. package/src/types/TracePage.ts +18 -0
  233. package/src/types.ts +17 -0
@@ -0,0 +1,58 @@
1
+ import type { Recording } from '../specs/Recording.nitro'
2
+ import type { TracePage } from '../types'
3
+ /** Bound work to retained history at read start, not an arbitrary UI result limit. */
4
+ export async function readSnapshot(
5
+ recording: Recording,
6
+ previous?: TracePage
7
+ ): Promise<TracePage> {
8
+ const page: TracePage = {
9
+ spans: [],
10
+ marks: [],
11
+ metrics: [],
12
+ nextSequence: previous?.nextSequence ?? 0,
13
+ earliestSequence: 0,
14
+ droppedEvents: 0,
15
+ }
16
+ const budget = Math.max(1, Math.ceil(recording.getStats().eventCount / 1000))
17
+ for (let i = 0; i < budget; i++) {
18
+ const batch = await recording.readEvents({
19
+ afterSequence: page.nextSequence,
20
+ limit: 1000,
21
+ })
22
+ const cursor = page.nextSequence
23
+ page.spans.push(...batch.spans)
24
+ page.marks.push(...batch.marks)
25
+ page.metrics.push(...batch.metrics)
26
+ page.nextSequence = batch.nextSequence
27
+ page.earliestSequence = batch.earliestSequence
28
+ page.droppedEvents = batch.droppedEvents
29
+ if (
30
+ batch.nextSequence === cursor ||
31
+ batch.spans.length + batch.marks.length + batch.metrics.length < 1000
32
+ )
33
+ break
34
+ }
35
+ if (!previous) return page
36
+ if (
37
+ page.nextSequence === previous.nextSequence &&
38
+ page.earliestSequence === previous.earliestSequence &&
39
+ page.droppedEvents === previous.droppedEvents
40
+ )
41
+ return previous
42
+ const keep = <T extends { sequence: number }>(old: T[], added: T[]): T[] => {
43
+ const evicted = old.length > 0 && old[0].sequence < page.earliestSequence
44
+ if (!evicted && added.length === 0) return old
45
+ return [
46
+ ...(evicted
47
+ ? old.filter((event) => event.sequence >= page.earliestSequence)
48
+ : old),
49
+ ...added,
50
+ ]
51
+ }
52
+ return {
53
+ ...page,
54
+ spans: keep(previous.spans, page.spans),
55
+ marks: keep(previous.marks, page.marks),
56
+ metrics: keep(previous.metrics, page.metrics),
57
+ }
58
+ }
@@ -0,0 +1,31 @@
1
+ import { useSyncExternalStore } from 'react'
2
+ /** Minimal external store so only subscribed components rerender on telemetry ticks. */
3
+ export interface Store<T> {
4
+ get(): T
5
+ set(patch: Partial<T>): void
6
+ subscribe(listener: () => void): () => void
7
+ }
8
+ export function createStore<T extends object>(initial: T): Store<T> {
9
+ let value = initial
10
+ const listeners = new Set<() => void>()
11
+ return {
12
+ get: () => value,
13
+ set(patch) {
14
+ value = { ...value, ...patch }
15
+ for (const listener of listeners) listener()
16
+ },
17
+ subscribe(listener) {
18
+ listeners.add(listener)
19
+ return () => {
20
+ listeners.delete(listener)
21
+ }
22
+ },
23
+ }
24
+ }
25
+ /** Selectors must return primitives or existing references. */
26
+ export const useStore = <T, S>(store: Store<T>, select: (value: T) => S): S =>
27
+ useSyncExternalStore(
28
+ store.subscribe,
29
+ () => select(store.get()),
30
+ () => select(store.get())
31
+ )
@@ -0,0 +1,427 @@
1
+ import type { MarkEvent, MetricEvent, SpanEvent, TracePage } from '../types'
2
+
3
+ export type TopicId =
4
+ | 'startup'
5
+ | 'screens'
6
+ | 'network'
7
+ | 'responsiveness'
8
+ | 'resources'
9
+ | 'errors'
10
+ | 'custom'
11
+ export const topicIds: TopicId[] = [
12
+ 'startup',
13
+ 'screens',
14
+ 'network',
15
+ 'responsiveness',
16
+ 'resources',
17
+ 'errors',
18
+ 'custom',
19
+ ]
20
+ /** Text-presentation glyphs (no color emoji) and system tints per topic. */
21
+ export const topicGlyph: Record<TopicId, { icon: string; color: string }> = {
22
+ startup: { icon: '◴', color: '#ff9500' },
23
+ screens: { icon: '▢', color: '#007aff' },
24
+ network: { icon: '⇅', color: '#34c759' },
25
+ responsiveness: { icon: '◷', color: '#5856d6' },
26
+ errors: { icon: '△', color: '#ff3b30' },
27
+ resources: { icon: '◫', color: '#30b0c7' },
28
+ custom: { icon: '•', color: '#8e8e93' },
29
+ }
30
+ /** Local triage thresholds, not production SLOs. */
31
+ export interface Budgets {
32
+ appReadyMs: number
33
+ screenMs: number
34
+ requestMs: number
35
+ stallMs: number
36
+ }
37
+ export const defaultBudgets: Budgets = {
38
+ appReadyMs: 2000,
39
+ screenMs: 1000,
40
+ requestMs: 1000,
41
+ stallMs: 250,
42
+ }
43
+ export type AnyEvent = SpanEvent | MarkEvent | MetricEvent
44
+ export type IssueKind =
45
+ | 'slowStartup'
46
+ | 'slowScreen'
47
+ | 'slowRequest'
48
+ | 'failedRequest'
49
+ | 'stall'
50
+ | 'uiStall'
51
+ | 'frozenFrames'
52
+ | 'error'
53
+ export interface Issue {
54
+ kind: IssueKind
55
+ topic: TopicId
56
+ title: string
57
+ valueMs?: number
58
+ budgetMs?: number
59
+ event: AnyEvent
60
+ /** Errors outrank budget overruns; overruns rank by value / budget. */
61
+ severity: number
62
+ }
63
+ export interface TopicSummary {
64
+ id: TopicId
65
+ /** A collector is configured or the topic has events. */
66
+ tracked: boolean
67
+ count: number
68
+ /** Nearest-rank p95 (screens: transition, network: duration) or latest app-ready. */
69
+ value?: number
70
+ /** Extra headline values, e.g. resources: latest cpu and memory. */
71
+ values?: Record<string, string>
72
+ issues: Issue[]
73
+ }
74
+ /** Collector plugin IDs that make a quiet topic "tracked" rather than "not tracked". */
75
+ const collectorsFor: Record<TopicId, string[]> = {
76
+ startup: [],
77
+ screens: ['navigation'],
78
+ network: ['network', 'sentry'],
79
+ responsiveness: ['runtime-metrics', 'native-metrics', 'sentry'],
80
+ resources: ['native-metrics'],
81
+ errors: ['errors'],
82
+ custom: [],
83
+ }
84
+ export const attribute = (event: AnyEvent, key: string) =>
85
+ event.attributes.find((a) => a.key === key)?.value
86
+ const isSpan = (event: AnyEvent): event is SpanEvent => 'spanId' in event
87
+ const isMetric = (event: AnyEvent): event is MetricEvent => 'unit' in event
88
+
89
+ export const topicOf = (event: AnyEvent): TopicId => {
90
+ const source = attribute(event, 'source')
91
+ if (
92
+ event.name.startsWith('app.ready.') ||
93
+ event.name.startsWith('app.start.')
94
+ )
95
+ return 'startup'
96
+ if (event.name.startsWith('process.')) return 'resources'
97
+ if (event.name.startsWith('ui.')) return 'responsiveness'
98
+ if (source === 'navigation') return 'screens'
99
+ if (source === 'network') return 'network'
100
+ if (source === 'error') return 'errors'
101
+ if (source === 'js-runtime' || event.name === 'js.longtask')
102
+ return 'responsiveness'
103
+ return 'custom'
104
+ }
105
+ const p95 = (values: number[]) => {
106
+ if (!values.length) return undefined
107
+ const sorted = [...values].sort((a, b) => a - b)
108
+ return sorted[Math.max(0, Math.ceil(sorted.length * 0.95) - 1)]
109
+ }
110
+ const over = (
111
+ kind: IssueKind,
112
+ topic: TopicId,
113
+ title: string,
114
+ event: AnyEvent,
115
+ valueMs: number,
116
+ budgetMs: number
117
+ ): Issue | undefined =>
118
+ valueMs > budgetMs
119
+ ? {
120
+ kind,
121
+ topic,
122
+ title,
123
+ event,
124
+ valueMs,
125
+ budgetMs,
126
+ severity: valueMs / budgetMs,
127
+ }
128
+ : undefined
129
+
130
+ export const summarizeTopics = (
131
+ page: TracePage,
132
+ collectors: readonly string[],
133
+ budgets: Budgets = defaultBudgets
134
+ ): { topics: TopicSummary[]; issues: Issue[] } => {
135
+ const byTopic = new Map<TopicId, AnyEvent[]>(topicIds.map((id) => [id, []]))
136
+ for (const event of [...page.spans, ...page.marks, ...page.metrics])
137
+ byTopic.get(topicOf(event))!.push(event)
138
+ const issues: Issue[] = []
139
+ const add = (issue: Issue | undefined) => issue && issues.push(issue)
140
+ for (const event of [...page.spans, ...page.marks, ...page.metrics]) {
141
+ const topic = topicOf(event)
142
+ if (isMetric(event)) {
143
+ if (topic === 'startup')
144
+ add(
145
+ over(
146
+ 'slowStartup',
147
+ topic,
148
+ event.name,
149
+ event,
150
+ event.value,
151
+ budgets.appReadyMs
152
+ )
153
+ )
154
+ else if (event.name === 'navigation.transition')
155
+ add(
156
+ over(
157
+ 'slowScreen',
158
+ topic,
159
+ attribute(event, 'screen') ?? event.name,
160
+ event,
161
+ event.value,
162
+ budgets.screenMs
163
+ )
164
+ )
165
+ else if (event.name === 'ui.frame_gap.max')
166
+ add(
167
+ over(
168
+ 'uiStall',
169
+ topic,
170
+ event.name,
171
+ event,
172
+ event.value,
173
+ budgets.stallMs
174
+ )
175
+ )
176
+ else if (event.name === 'ui.frames.frozen' && event.value > 0)
177
+ add({
178
+ kind: 'frozenFrames',
179
+ topic,
180
+ title: `${event.value} frozen frames`,
181
+ event,
182
+ severity: 900,
183
+ })
184
+ else if (
185
+ event.name === 'js.frame_gap.max' ||
186
+ event.name === 'js.event_loop.delay'
187
+ )
188
+ add(
189
+ over('stall', topic, event.name, event, event.value, budgets.stallMs)
190
+ )
191
+ } else if (isSpan(event)) {
192
+ if (topic === 'network')
193
+ add(
194
+ event.outcome === 'error'
195
+ ? {
196
+ kind: 'failedRequest',
197
+ topic,
198
+ title: event.name,
199
+ event,
200
+ severity: 1000,
201
+ }
202
+ : over(
203
+ 'slowRequest',
204
+ topic,
205
+ event.name,
206
+ event,
207
+ event.durationMs,
208
+ budgets.requestMs
209
+ )
210
+ )
211
+ else if (event.name === 'js.longtask')
212
+ add(
213
+ over(
214
+ 'stall',
215
+ topic,
216
+ event.name,
217
+ event,
218
+ event.durationMs,
219
+ budgets.stallMs
220
+ )
221
+ )
222
+ else if (event.outcome === 'error')
223
+ add({
224
+ kind: 'error',
225
+ topic: 'errors',
226
+ title: event.name,
227
+ event,
228
+ severity: 1000,
229
+ })
230
+ } else if (topic === 'errors')
231
+ add({
232
+ kind: 'error',
233
+ topic,
234
+ title: attribute(event, 'message') ?? event.name,
235
+ event,
236
+ severity: 1000,
237
+ })
238
+ }
239
+ issues.sort(
240
+ (a, b) =>
241
+ b.severity - a.severity || b.event.timestampMs - a.event.timestampMs
242
+ )
243
+ const topics = topicIds.map((id): TopicSummary => {
244
+ const events = byTopic.get(id)!
245
+ const own = issues.filter((issue) => issue.topic === id)
246
+ const metricValues = (name: string) =>
247
+ events
248
+ .filter((e): e is MetricEvent => isMetric(e) && e.name === name)
249
+ .map((e) => e.value)
250
+ const spans = events.filter(isSpan)
251
+ const summary = {
252
+ id,
253
+ tracked:
254
+ id === 'custom' ||
255
+ events.length > 0 ||
256
+ collectorsFor[id].some((collector) => collectors.includes(collector)),
257
+ issues: own,
258
+ }
259
+ switch (id) {
260
+ case 'startup':
261
+ return {
262
+ ...summary,
263
+ count: events.length,
264
+ value: events.filter(isMetric).at(-1)?.value,
265
+ }
266
+ case 'screens':
267
+ return {
268
+ ...summary,
269
+ count: events.filter((e) => e.name === 'navigation.enter').length,
270
+ value: p95(metricValues('navigation.transition')),
271
+ }
272
+ case 'network':
273
+ return {
274
+ ...summary,
275
+ count: spans.length,
276
+ value: p95(spans.map((s) => s.durationMs)),
277
+ }
278
+ case 'resources': {
279
+ const latest = (name: string) => {
280
+ const values = metricValues(name)
281
+ return values.length ? values[values.length - 1].toFixed(0) : '—'
282
+ }
283
+ return {
284
+ ...summary,
285
+ count: events.length,
286
+ values: {
287
+ cpu: latest('process.cpu'),
288
+ memory: latest('process.memory'),
289
+ },
290
+ }
291
+ }
292
+ case 'responsiveness':
293
+ case 'errors':
294
+ return { ...summary, count: own.length }
295
+ default:
296
+ return { ...summary, count: spans.length }
297
+ }
298
+ })
299
+ return { topics, issues }
300
+ }
301
+
302
+ export type TimelineItem =
303
+ | { kind: 'screen'; key: string; name: string; timestampMs: number }
304
+ | {
305
+ kind: 'event'
306
+ key: string
307
+ event: AnyEvent
308
+ topic: TopicId
309
+ issue: boolean
310
+ }
311
+ /**
312
+ * Newest screen visit first, each followed by its events newest first. Visits come from
313
+ * `navigation.enter` marks, so the active screen appears before its visit span completes.
314
+ * Metrics are listed only when they are issues; periodic samples belong in Metrics.
315
+ */
316
+ export const buildTimeline = (
317
+ page: TracePage,
318
+ issues: readonly Issue[],
319
+ limit = 500
320
+ ): TimelineItem[] => {
321
+ const flagged = new Set<AnyEvent>(issues.map((issue) => issue.event))
322
+ const visits = page.marks
323
+ .filter(
324
+ (m) =>
325
+ m.name === 'navigation.enter' && attribute(m, 'source') === 'navigation'
326
+ )
327
+ .sort((a, b) => a.timestampMs - b.timestampMs)
328
+ const visitSet = new Set<AnyEvent>(visits)
329
+ const events = [
330
+ ...page.spans.filter((s) => attribute(s, 'source') !== 'navigation'),
331
+ ...page.marks.filter((m) => !visitSet.has(m)),
332
+ ...page.metrics.filter((m) => flagged.has(m)),
333
+ ]
334
+ .sort((a, b) => b.timestampMs - a.timestampMs || b.sequence - a.sequence)
335
+ .slice(0, limit)
336
+ const groups = new Map<number, AnyEvent[]>()
337
+ for (const event of events) {
338
+ // ponytail: linear scan per event; binary search if visit counts grow large.
339
+ let index = -1
340
+ for (let i = visits.length - 1; i >= 0; i--)
341
+ if (visits[i].timestampMs <= event.timestampMs) {
342
+ index = i
343
+ break
344
+ }
345
+ const group = groups.get(index) ?? []
346
+ group.push(event)
347
+ groups.set(index, group)
348
+ }
349
+ const items: TimelineItem[] = []
350
+ for (let index = visits.length - 1; index >= -1; index--) {
351
+ const group = groups.get(index) ?? []
352
+ const visit = visits[index]
353
+ if (!visit && !group.length) continue
354
+ if (visits.length)
355
+ items.push({
356
+ kind: 'screen',
357
+ key: visit ? `screen:${visit.sequence}` : 'screen:before',
358
+ name: visit ? (attribute(visit, 'screen') ?? '') : '',
359
+ timestampMs: visit?.timestampMs ?? 0,
360
+ })
361
+ for (const event of group)
362
+ items.push({
363
+ kind: 'event',
364
+ key: `${'spanId' in event ? event.spanId : 'unit' in event ? 'metric' : 'mark'}:${event.sequence}`,
365
+ event,
366
+ topic: topicOf(event),
367
+ issue: flagged.has(event),
368
+ })
369
+ }
370
+ return items
371
+ }
372
+ export const currentScreen = (page: TracePage) => {
373
+ let latest: MarkEvent | undefined
374
+ for (const mark of page.marks)
375
+ if (
376
+ mark.name === 'navigation.enter' &&
377
+ (!latest || mark.timestampMs >= latest.timestampMs)
378
+ )
379
+ latest = mark
380
+ return latest && attribute(latest, 'screen')
381
+ }
382
+
383
+ export interface VisitSummary {
384
+ /** Undefined when no navigation collector has recorded a screen yet. */
385
+ screen?: string
386
+ sinceMs?: number
387
+ events: Extract<TimelineItem, { kind: 'event' }>[]
388
+ requests: number
389
+ stalls: number
390
+ errors: number
391
+ issues: number
392
+ }
393
+ /** The newest screen visit in a timeline: what happened on the screen the user is looking at. */
394
+ export const currentVisit = (items: readonly TimelineItem[]): VisitSummary => {
395
+ const start = items.findIndex((item) => item.kind === 'screen')
396
+ const header = start >= 0 ? items[start] : undefined
397
+ const next = items.findIndex((item, i) => i > start && item.kind === 'screen')
398
+ const events = items
399
+ .slice(start + 1, start >= 0 && next >= 0 ? next : undefined)
400
+ .filter((item) => item.kind === 'event')
401
+ const count = (test: (item: (typeof events)[number]) => boolean) =>
402
+ events.filter(test).length
403
+ return {
404
+ screen: header?.kind === 'screen' ? header.name : undefined,
405
+ sinceMs: header?.kind === 'screen' ? header.timestampMs : undefined,
406
+ events,
407
+ requests: count((item) => item.topic === 'network'),
408
+ stalls: count((item) => item.topic === 'responsiveness' && item.issue),
409
+ errors: count(
410
+ (item) =>
411
+ item.topic === 'errors' ||
412
+ ('outcome' in item.event && item.event.outcome === 'error')
413
+ ),
414
+ issues: count((item) => item.issue),
415
+ }
416
+ }
417
+ /** Latest sample of a metric by name; undefined when not collected. */
418
+ export const latestMetric = (page: TracePage, name: string) => {
419
+ let latest: MetricEvent | undefined
420
+ for (const metric of page.metrics)
421
+ if (
422
+ metric.name === name &&
423
+ (!latest || metric.timestampMs >= latest.timestampMs)
424
+ )
425
+ latest = metric
426
+ return latest?.value
427
+ }
@@ -0,0 +1,10 @@
1
+ import { useEffect } from 'react'
2
+ import type { TraceClient } from '../client/createTraceClient'
3
+ /** Report the consumer's ready signal after a frame opportunity. Not native launch time or guaranteed TTI. */
4
+ export function useAppReadyMetric(client: TraceClient, ready: boolean) {
5
+ useEffect(() => {
6
+ if (!ready) return
7
+ const frame = requestAnimationFrame(() => client.reportAppReady())
8
+ return () => cancelAnimationFrame(frame)
9
+ }, [client, ready])
10
+ }
@@ -0,0 +1,43 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { TraceClient } from '../client/createTraceClient'
3
+ import type { Recording } from '../specs/Recording.nitro'
4
+ import type { TracePage } from '../types'
5
+ import { readSnapshot } from './readSnapshot'
6
+ /** Incremental polling of the active recording; unchanged snapshots keep their identity. */
7
+ export function useLiveRecording(
8
+ client: TraceClient,
9
+ active: boolean,
10
+ intervalMs = 1000
11
+ ) {
12
+ const [page, setPage] = useState<TracePage>()
13
+ useEffect(() => {
14
+ if (!active) return
15
+ let cancelled = false
16
+ let timer: ReturnType<typeof setTimeout>
17
+ let cached: TracePage | undefined
18
+ let cachedRecording: Recording | undefined
19
+ const tick = async () => {
20
+ try {
21
+ const recording = client.getRecording()
22
+ if (recording) {
23
+ if (recording !== cachedRecording) {
24
+ cached = undefined
25
+ cachedRecording = recording
26
+ }
27
+ cached = await readSnapshot(recording, cached)
28
+ if (!cancelled) setPage(cached)
29
+ }
30
+ } catch (error) {
31
+ client.reportError(error)
32
+ } finally {
33
+ if (!cancelled) timer = setTimeout(tick, intervalMs)
34
+ }
35
+ }
36
+ void tick()
37
+ return () => {
38
+ cancelled = true
39
+ clearTimeout(timer)
40
+ }
41
+ }, [client, active, intervalMs])
42
+ return page
43
+ }