feat(telemetry): add Studio Tool Mount Time Measured event#12708
feat(telemetry): add Studio Tool Mount Time Measured event#12708
Conversation
Instruments Gap 2 from the studio-performance-tracking plan: measure
how long each tool (desk, presentation, vision, custom plugins) takes
to render its first commit after activation. Identifies slow custom
plugins — since `activeTool` is already auto-enriched on every
`TelemetryContext`, we can slice by tool without duplicating it into
the event payload.
Adds one event under the existing `<Feature> <Metric> Measured`
convention:
- `Studio Tool Mount Time Measured` — fires on every tool activation,
not once per session. Payload: `toolName`, `durationMs`,
`isFirstMount`.
Implementation:
- New `ToolMountTimer` component rendered invisibly inside the active
tool's `<Suspense>` boundary. Its first `useEffect` runs only after
the lazy chunk resolves and the tool's first render commits, giving
us a fair "tool is on screen" moment.
- T0 captured via `useMemo` keyed on `activeToolName` in
`StudioLayoutComponent` — stable per activation, no render-time ref
writes.
- The existing `<StudioErrorBoundary key={activeTool?.name}>` already
re-keys on tool switch, so the timer naturally unmounts/remounts per
tool, producing exactly one event per activation.
- Module-level `mountedTools: Set<string>` tracks which tools have
already been activated this page session, so `isFirstMount`
distinguishes cold-start (includes lazy-chunk fetch) from warm
switches back to an already-mounted tool.
v1 measures only to first React commit. A plugin-facing `Tool.onReady`
lifecycle hook for tools that do async work post-mount is explicitly
deferred to v2 (see implementation plan D2.1).
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📦 Bundle Stats —
|
| Metric | Value | vs main (9060f82) | vs v5.21.0 |
|---|---|---|---|
| Internal (raw) | 4.42 MB | +1.5 KB, +0.0% | +5.0 KB, +0.1% |
| Internal (gzip) | 1.02 MB | +459 B, +0.0% | +1.3 KB, +0.1% |
| Bundled (raw) | 12.09 MB | +1.5 KB, +0.0% | -37.8 KB, -0.3% |
| Bundled (gzip) | 2.72 MB | +458 B, +0.0% | -7.1 KB, -0.3% |
| Import time | 1.62s | -5ms, -0.3% | +84ms, +5.5% |
bin:sanity
| Metric | Value | vs main (9060f82) | vs v5.21.0 |
|---|---|---|---|
| Internal (raw) | 7.1 KB | - | - |
| Internal (gzip) | 2.9 KB | - | - |
| Bundled (raw) | 7.1 KB | - | - |
| Bundled (gzip) | 2.8 KB | - | - |
| Import time | 6ms | +0ms, +1.9% | +1ms, +12.6% |
🗺️ View treemap · Artifacts
Details
- Import time regressions over 10% are flagged with
⚠️ - Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.
✅ E2E Tests🟢 186 passed • 🟡 6 flaky • (⚪ 90 skipped) • view full report • view run |
📚 TypeDoc Generation Result✅ TypeDoc generated successfully!
The TypeDoc JSON file has been generated and validated. All documentation scripts completed successfully. |
⚡️ Editor Performance ReportUpdated Tue, 21 Apr 2026 08:45:30 GMT
Detailed information🏠 Reference resultThe performance result of
🧪 Experiment resultThe performance result of this branch
📚 Glossary
|
⚡️ Editor Performance ReportDeploying studio and running performance tests… |
- Replace impure useMemo(performance.now()) with a ref written
inside an effect on activeToolName change. React Compiler forbids
calling impure functions during render; the effect still runs
before Suspense resolves (the fallback renders first), so the
delta measured in ToolMountTimer still includes lazy-chunk time.
- Pass t0 via RefObject rather than a value prop, so the timer
reads it in its own effect (post-render).
- Remove dead 'eslint-disable-next-line react-hooks/exhaustive-deps'
on ToolMountTimer's effect; use proper deps [telemetry, toolName,
t0Ref], all stable within a single mount (parent re-keys the
StudioErrorBoundary per tool, so one mount = one tool activation).
- Remove two unused 'eslint-disable-next-line react-hooks/static-
components' comments (ESLint reported them as unused).
- Replace 'typeof import("...")' type annotations in the test file
with top-of-file 'import type' to satisfy
@typescript-eslint/consistent-type-imports.
- Add a new test case: does not fire when t0Ref.current is null
(covers the new guard path).
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||
…iveToolLayout Per @studiolead: my earlier patch removed two eslint-disable comments that ESLint had flagged as 'unused' at pre-patch line numbers. The disables were actually still needed — just moved line numbers because my diff shifted them. Post-patch they fired: 205:10 Cannot create components during render (on <Navbar />) 232:18 Cannot create components during render (on <ActiveToolLayout ... />) Both components come from hooks (useNavbarComponent, useActiveToolLayoutComponent) that return middleware-resolved component references. That's the intended pattern but trips react-hooks/static-components. Restored with '-- reason' explanations so the disables are self-documenting. Also: oxfmt formatting on ToolMountTimer.test.tsx (import order + inline one render() call that fit under 100 chars).
Satisfies import/consistent-type-specifier-style. The repo convention
is '{type X}' inline, not 'import type {X}'.
|
Rebased onto current |
|
Correction on my previous comment — checking more carefully, this branch was already up to date with main at the tip ( The stale-base problem only applied to #12707, #12709, #12710. |
Summary
Instruments Gap 2 from the studio performance tracking plan: measure how long each tool takes to render its first commit after activation. Identifies slow custom plugins. Since
activeToolis already auto-enriched on everyTelemetryContext, we slice by tool without duplicating it into the event payload.Adds one event under the existing
<Feature> <Metric> Measuredconvention:Studio Tool Mount Time MeasuredToolMountTimer(inside the active tool's<Suspense>)toolName,durationMs,isFirstMountFires on every tool activation, not once per session.
How it works
ToolMountTimercomponent rendered invisibly inside the active tool's<Suspense>boundary. Its firstuseEffectruns only after the lazy chunk resolves and the tool's first render commits, giving a fair "tool is on screen" moment.useMemokeyed onactiveToolNameinStudioLayoutComponent— stable per activation, no render-time ref writes.<StudioErrorBoundary key={activeTool?.name}>already re-keys on tool switch, so the timer naturally unmounts/remounts per tool, producing exactly one event per activation.mountedTools: Set<string>tracks which tools have already been activated this page session, soisFirstMountdistinguishes cold-start (includes lazy-chunk fetch) from warm switches back to an already-mounted tool.v1 scope note
v1 measures only to first React commit. A plugin-facing
Tool.onReadylifecycle hook for tools that do async work post-mount (e.g. Vision fetching CORS origins) is explicitly deferred to v2 — see the telemetry gaps implementation plan, D2.1. Plugins that do significant async work after mount will under-report here; we'll extend the instrumentation if the data motivates it.Files touched
packages/sanity/src/core/studio/__telemetry__/tools.telemetry.ts— event definitionpackages/sanity/src/core/studio/ToolMountTimer.tsx— invisible timer componentpackages/sanity/src/core/studio/StudioLayout.tsx— capture T0 withuseMemo, render<ToolMountTimer>inside<Suspense>packages/sanity/src/core/studio/__tests__/ToolMountTimer.test.tsxTests
Cover:
isFirstMount: trueon first activationisFirstMount: false(warm switch)isFirstMount: true(tracked separately)durationMscorrectly reflects the delta from the providedt0Manual verification
Run with
SANITY_STUDIO_DEBUG_TELEMETRY=true pnpm dev. Open the Studio, switch between structure / vision / presentation / any custom plugin — each switch produces oneStudio Tool Mount Time Measuredevent. Switching back to a previously-visited tool setsisFirstMount: false.Scope
Draft. Second of four PRs staged from
/research/telemetry-gaps-implementation-plan.md(internal); Gap 1 is #12707. Does not change any plugin-facing API (that's deferred to v2). Sequential with Gap 4 (search latency) and Gap 3 (document initial load) to follow.