Skip to content

Commit 1ce6bfd

Browse files
committed
perf(tauri): instrument desktop startup
1 parent 6961efd commit 1ce6bfd

6 files changed

Lines changed: 344 additions & 2 deletions

File tree

packages/tauri-app/src-tauri/src/cli_manager.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ fn log_line(message: &str) {
2929
println!("[tauri-cli] {message}");
3030
}
3131

32+
fn emit_perf_startup(app: &AppHandle, stage: &str, detail: serde_json::Value) {
33+
let _ = app.emit(
34+
"perf:startup",
35+
json!({
36+
"stage": stage,
37+
"detail": detail,
38+
}),
39+
);
40+
}
41+
3242
#[cfg(windows)]
3343
fn configure_spawn(command: &mut Command) {
3444
command.creation_flags(CREATE_NO_WINDOW);
@@ -358,6 +368,7 @@ impl CliProcessManager {
358368

359369
pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> {
360370
log_line(&format!("start requested (dev={dev})"));
371+
emit_perf_startup(&app, "cli.start.requested", json!({ "dev": dev }));
361372
self.stop()?;
362373
self.ready.store(false, Ordering::SeqCst);
363374
*self.bootstrap_token.lock() = None;
@@ -390,6 +401,11 @@ impl CliProcessManager {
390401
locked.error = Some(err.to_string());
391402
let snapshot = locked.clone();
392403
drop(locked);
404+
emit_perf_startup(
405+
&app,
406+
"cli.start.error",
407+
json!({ "message": err.to_string() }),
408+
);
393409
let _ = app.emit("cli:error", json!({"message": err.to_string()}));
394410
let _ = app.emit("cli:status", snapshot);
395411
}
@@ -478,6 +494,15 @@ impl CliProcessManager {
478494
log_line("resolving CLI entry");
479495
let resolution = CliEntry::resolve(&app, dev)?;
480496
let host = resolve_listening_host();
497+
emit_perf_startup(
498+
&app,
499+
"cli.entry.resolved",
500+
json!({
501+
"dev": dev,
502+
"runner": format!("{:?}", resolution.runner),
503+
"host": host,
504+
}),
505+
);
481506
log_line(&format!(
482507
"resolved CLI entry runner={:?} entry={} host={}",
483508
resolution.runner, resolution.entry, host
@@ -547,6 +572,7 @@ impl CliProcessManager {
547572

548573
let pid = child.id();
549574
log_line(&format!("spawned pid={pid}"));
575+
emit_perf_startup(&app, "cli.process.spawned", json!({ "pid": pid }));
550576
{
551577
let mut locked = status.lock();
552578
locked.pid = Some(pid);
@@ -632,6 +658,11 @@ impl CliProcessManager {
632658
let _ = child.kill();
633659
}
634660
}
661+
emit_perf_startup(
662+
&app_clone,
663+
"cli.start.timeout",
664+
json!({ "message": "CLI did not start in time" }),
665+
);
635666
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
636667
Self::emit_status(&app_clone, &locked);
637668
});
@@ -836,6 +867,7 @@ impl CliProcessManager {
836867
navigate_main(app, &base_url);
837868
}
838869
let _ = app.emit("cli:ready", locked.clone());
870+
emit_perf_startup(app, "cli.ready", json!({ "url": base_url }));
839871
Self::emit_status(app, &locked);
840872
}
841873

packages/tauri-app/src-tauri/src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ fn main() {
146146
.setup(|app| {
147147
set_windows_app_user_model_id();
148148
build_menu(&app.handle())?;
149+
let _ = app
150+
.handle()
151+
.emit("perf:startup", json!({"stage": "tauri.setup.complete"}));
149152
let dev_mode = is_dev_mode();
150153
let app_handle = app.handle().clone();
151154
let manager = app.state::<AppState>().manager.clone();

packages/ui/src/components/message-section.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
1818
import type { DeleteHoverState } from "../types/delete-hover"
1919
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
2020
import { getPartCharCount } from "../lib/token-utils"
21+
import { markPerf, measurePerf } from "../lib/perf"
2122

2223
const SCROLL_SENTINEL_MARGIN_PX = 48
2324
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
@@ -46,6 +47,7 @@ export default function MessageSection(props: MessageSectionProps) {
4647
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
4748
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
4849
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
50+
let firstContentPerfRecorded = false
4951

5052
const scrollCache = useScrollCache({
5153
instanceId: props.instanceId,
@@ -69,6 +71,39 @@ export default function MessageSection(props: MessageSectionProps) {
6971
},
7072
)
7173

74+
createEffect(on(() => props.sessionId, () => {
75+
firstContentPerfRecorded = false
76+
}))
77+
78+
createEffect(() => {
79+
const count = messageIds().length
80+
if (firstContentPerfRecorded || count === 0) {
81+
return
82+
}
83+
84+
firstContentPerfRecorded = true
85+
queueMicrotask(() => {
86+
if (typeof requestAnimationFrame === "function") {
87+
requestAnimationFrame(() => {
88+
markPerf("ui.session.first-content", {
89+
instanceId: props.instanceId,
90+
sessionId: props.sessionId,
91+
messageCount: count,
92+
})
93+
measurePerf("ui.bootstrap_to_first_content", "ui.bootstrap.start", "ui.session.first-content")
94+
})
95+
return
96+
}
97+
98+
markPerf("ui.session.first-content", {
99+
instanceId: props.instanceId,
100+
sessionId: props.sessionId,
101+
messageCount: count,
102+
})
103+
measurePerf("ui.bootstrap_to_first_content", "ui.bootstrap.start", "ui.session.first-content")
104+
})
105+
})
106+
72107
const tokenStats = createMemo(() => {
73108
const usage = usageSnapshot()
74109
const info = sessionInfo()

packages/ui/src/lib/perf.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { runtimeEnv } from "./runtime-env"
2+
3+
type PerfDetail = Record<string, unknown> | undefined
4+
5+
export interface PerfTraceEntry {
6+
name: string
7+
time: number
8+
absoluteTime: number
9+
host: string
10+
platform: string
11+
path: string
12+
detail?: PerfDetail
13+
}
14+
15+
interface PerfSummary {
16+
runtimeHost: string
17+
runtimePlatform: string
18+
loaderToCliReadyMs?: number
19+
loaderToNavigateMs?: number
20+
bootstrapToAppMountMs?: number
21+
bootstrapToFirstFrameMs?: number
22+
bootstrapToFirstContentMs?: number
23+
}
24+
25+
const TRACE_STORAGE_KEY = "codenomad:perf-trace"
26+
const WINDOW_NAME_PREFIX = "__CODENOMAD_PERF__:"
27+
const TRACE_RETENTION_LIMIT = 200
28+
const TRACE_STALE_AFTER_MS = 10 * 60 * 1000
29+
30+
let inMemoryTrace: PerfTraceEntry[] | null = null
31+
32+
function nowMs() {
33+
return typeof performance !== "undefined" ? performance.now() : Date.now()
34+
}
35+
36+
function absoluteNowMs() {
37+
if (typeof performance !== "undefined" && typeof performance.timeOrigin === "number") {
38+
return performance.timeOrigin + performance.now()
39+
}
40+
return Date.now()
41+
}
42+
43+
function getCurrentPath() {
44+
if (typeof window === "undefined" || !window.location) {
45+
return ""
46+
}
47+
return `${window.location.pathname}${window.location.search}${window.location.hash}`
48+
}
49+
50+
function supportsSessionStorage() {
51+
return typeof window !== "undefined" && typeof window.sessionStorage !== "undefined"
52+
}
53+
54+
function loadTraceFromWindowName(): PerfTraceEntry[] {
55+
if (typeof window === "undefined" || !window.name.startsWith(WINDOW_NAME_PREFIX)) {
56+
return []
57+
}
58+
59+
try {
60+
const parsed = JSON.parse(window.name.slice(WINDOW_NAME_PREFIX.length))
61+
return Array.isArray(parsed) ? (parsed as PerfTraceEntry[]) : []
62+
} catch {
63+
return []
64+
}
65+
}
66+
67+
function persistTrace(trace: PerfTraceEntry[]) {
68+
const trimmed = trace.slice(-TRACE_RETENTION_LIMIT)
69+
inMemoryTrace = trimmed
70+
71+
if (supportsSessionStorage()) {
72+
try {
73+
window.sessionStorage.setItem(TRACE_STORAGE_KEY, JSON.stringify(trimmed))
74+
} catch {
75+
/* noop */
76+
}
77+
}
78+
79+
if (typeof window !== "undefined") {
80+
try {
81+
window.name = `${WINDOW_NAME_PREFIX}${JSON.stringify(trimmed)}`
82+
} catch {
83+
/* noop */
84+
}
85+
}
86+
87+
publishPerfHandle(trimmed)
88+
}
89+
90+
function readStoredTrace(): PerfTraceEntry[] {
91+
if (inMemoryTrace) {
92+
return inMemoryTrace
93+
}
94+
95+
let parsed: PerfTraceEntry[] = []
96+
97+
if (supportsSessionStorage()) {
98+
try {
99+
const raw = window.sessionStorage.getItem(TRACE_STORAGE_KEY)
100+
if (raw) {
101+
const value = JSON.parse(raw)
102+
if (Array.isArray(value)) {
103+
parsed = value as PerfTraceEntry[]
104+
}
105+
}
106+
} catch {
107+
parsed = []
108+
}
109+
}
110+
111+
if (parsed.length === 0) {
112+
parsed = loadTraceFromWindowName()
113+
}
114+
115+
const lastEntry = parsed[parsed.length - 1]
116+
if (lastEntry && Math.abs(absoluteNowMs() - lastEntry.absoluteTime) > TRACE_STALE_AFTER_MS) {
117+
parsed = []
118+
}
119+
120+
inMemoryTrace = parsed
121+
publishPerfHandle(parsed)
122+
return parsed
123+
}
124+
125+
function getFirstMarkTime(trace: PerfTraceEntry[], name: string) {
126+
const entry = trace.find((item) => item.name === name)
127+
return entry?.absoluteTime
128+
}
129+
130+
function durationBetween(trace: PerfTraceEntry[], start: string, end: string) {
131+
const startTime = getFirstMarkTime(trace, start)
132+
const endTime = getFirstMarkTime(trace, end)
133+
if (typeof startTime !== "number" || typeof endTime !== "number" || endTime < startTime) {
134+
return undefined
135+
}
136+
return Math.round((endTime - startTime) * 100) / 100
137+
}
138+
139+
export function getPerfTrace() {
140+
return [...readStoredTrace()]
141+
}
142+
143+
export function summarizePerfTrace(trace = readStoredTrace()): PerfSummary {
144+
return {
145+
runtimeHost: runtimeEnv.host,
146+
runtimePlatform: runtimeEnv.platform,
147+
loaderToCliReadyMs: durationBetween(trace, "loading.screen.mounted", "loading.tauri.cli.ready"),
148+
loaderToNavigateMs: durationBetween(trace, "loading.screen.mounted", "loading.navigate"),
149+
bootstrapToAppMountMs: durationBetween(trace, "ui.bootstrap.start", "ui.app.mounted"),
150+
bootstrapToFirstFrameMs: durationBetween(trace, "ui.bootstrap.start", "ui.app.first-frame"),
151+
bootstrapToFirstContentMs: durationBetween(trace, "ui.bootstrap.start", "ui.session.first-content"),
152+
}
153+
}
154+
155+
function publishPerfHandle(trace = readStoredTrace()) {
156+
if (typeof window === "undefined") {
157+
return
158+
}
159+
160+
;(window as typeof window & {
161+
__CODENOMAD_PERF__?: {
162+
getTrace: () => PerfTraceEntry[]
163+
getSummary: () => PerfSummary
164+
clear: () => void
165+
mark: (name: string, detail?: PerfDetail) => PerfTraceEntry
166+
}
167+
}).__CODENOMAD_PERF__ = {
168+
getTrace: () => getPerfTrace(),
169+
getSummary: () => summarizePerfTrace(trace),
170+
clear: () => clearPerfTrace(),
171+
mark: (name: string, detail?: PerfDetail) => markPerf(name, detail),
172+
}
173+
}
174+
175+
export function clearPerfTrace() {
176+
persistTrace([])
177+
}
178+
179+
export function beginPerfTrace(name: string, detail?: PerfDetail) {
180+
clearPerfTrace()
181+
return markPerf(name, detail)
182+
}
183+
184+
export function markPerf(name: string, detail?: PerfDetail): PerfTraceEntry {
185+
const entry: PerfTraceEntry = {
186+
name,
187+
time: nowMs(),
188+
absoluteTime: absoluteNowMs(),
189+
host: runtimeEnv.host,
190+
platform: runtimeEnv.platform,
191+
path: getCurrentPath(),
192+
detail,
193+
}
194+
195+
if (typeof performance !== "undefined" && typeof performance.mark === "function") {
196+
try {
197+
performance.mark(name)
198+
} catch {
199+
/* noop */
200+
}
201+
}
202+
203+
persistTrace([...readStoredTrace(), entry])
204+
return entry
205+
}
206+
207+
export function measurePerf(name: string, startMark: string, endMark: string) {
208+
if (typeof performance === "undefined" || typeof performance.measure !== "function") {
209+
return undefined
210+
}
211+
212+
try {
213+
return performance.measure(name, startMark, endMark)
214+
} catch {
215+
return undefined
216+
}
217+
}

0 commit comments

Comments
 (0)