Skip to content

feat: loading benchmark analytic - #9494

Merged
lorenzo-ranciaffi merged 15 commits into
devfrom
fix/loading-benchmark-analytic
Aug 10, 2026
Merged

feat: loading benchmark analytic#9494
lorenzo-ranciaffi merged 15 commits into
devfrom
fix/loading-benchmark-analytic

Conversation

@lorenzo-ranciaffi

@lorenzo-ranciaffi lorenzo-ranciaffi commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Pull Request Description

What does this PR change?

This PR adds a measure-loading-time app arg intended for CI usage only.
The CI will start the client with that flag so that it will log the startup loading times and send them to matrix in order to track how much time (and which stage) it takes for the client to land in GP (the CI will send the user to a dedicated world with a copy of GP so performance doesn't fluctuate based on users, emotes, ... ).
When the client has loaded it shuts down.

Test instructions

  1. Generic smoke test: all should operate as normal

Quality Checklist

  • Changes have been tested locally
  • Documentation has been updated (if required)
  • Performance impact has been considered
  • For SDK features: Test scene is included

Code Review Reference

Please review our Branch & PR Standards before submitting. It explains the automated review flow, QA/DEV approval requirements, and what each label does — especially useful for first-time contributors.

@lorenzo-ranciaffi lorenzo-ranciaffi self-assigned this Jul 28, 2026
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

badge

Warnings count reduced: 13940 => 13938

Warnings/errors in files changed by this PR (9)
Assets/DCL/PerformanceAndDiagnostics/SentryTransactionManager.cs:197  ArrangeRedundantParentheses  Redundant parentheses
Assets/DCL/PerformanceAndDiagnostics/Analytics/AnalyticsEvents.cs:28  InconsistentNaming  Name 'UI' does not match rule 'members_should_be_pascal_case'. Suggested name is 'Ui'.
Assets/DCL/UserInAppInitializationFlow/RealUserInAppInitializationFlow.cs:380  InconsistentNaming  Name 'withUI' does not match rule 'parameters_should_be_camel_case'. Suggested name is 'withUi'.
Assets/DCL/PerformanceAndDiagnostics/SentryTransactionManager.cs:291  VariableCanBeNotNullable  'spanStack' can be declared as non-nullable
Assets/DCL/PerformanceAndDiagnostics/SentryTransactionManager.cs:338  VariableCanBeNotNullable  'spanStack' can be declared as non-nullable
Assets/DCL/PerformanceAndDiagnostics/SentryTransactionManager.cs:78  VariableCanBeNotNullable  'transaction' can be declared as non-nullable
Assets/DCL/PerformanceAndDiagnostics/SentryTransactionManager.cs:89  VariableCanBeNotNullable  'transaction' can be declared as non-nullable
Assets/DCL/PerformanceAndDiagnostics/SentryTransactionManager.cs:100  VariableCanBeNotNullable  'transaction' can be declared as non-nullable
Assets/DCL/UserInAppInitializationFlow/RealUserInAppInitializationFlow.cs:177  VariableHidesOuterVariable  Parameter 'ct' hides outer parameter with the same name

@lorenzo-ranciaffi lorenzo-ranciaffi changed the title Fix/loading benchmark analytic feat: loading benchmark analytic Aug 3, 2026
@lorenzo-ranciaffi lorenzo-ranciaffi added the no QA needed Used to tag pull requests that does not require QA validation label Aug 3, 2026
@lorenzo-ranciaffi
lorenzo-ranciaffi marked this pull request as ready for review August 3, 2026 10:01
@lorenzo-ranciaffi
lorenzo-ranciaffi requested review from a team as code owners August 3, 2026 10:01
@github-actions
github-actions Bot requested review from dalkia and lorux0 August 3, 2026 10:02
@decentraland-bot
decentraland-bot self-requested a review August 3, 2026 10:02

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #9494: feat: loading benchmark analytic

STEP 2 — Root-cause check

PASS. This PR adds a new CI-only feature for benchmarking loading times — it is solving a genuine observability gap, not patching a symptom.

STEP 3 — Design & integration

Owner search for LoadingTimes (the new orchestrator):

  • Entity managed: loading-stage measurement lifecycle.
  • Existing owners: LoadingStatus (drives stage transitions via SetCurrentStage()), RealUserInAppInitializationFlow (calls SetCurrentStage during init), ProfilingPlugin (home for profiling/CI concerns, already hosts AutoPilot).
  • Verdict: ProfilingPlugin is the correct home for this CI-only feature. The subscription to ILoadingStatus.CurrentStageMut.OnUpdate is the right integration point — it fires on each stage transition without coupling to the flow that drives them.

However, LoadingTimeSampler is a second unit that exists only as a static delegate of LoadingTimes. It has no independent consumers and no polymorphism. Per CLAUDE.md §11 ("Extracting when you should merge"), it should be merged into LoadingTimes as instance state.

Teardown trace:

  • += OnStageUpdatedLoadingTimes constructor (line 23) ✅
  • -= OnStageUpdatedLoadingTimes.Dispose() (line 28) ✅
  • loadingTimes?.Dispose()ProfilingPlugin.Dispose()
  • ⚠️ In practice, Application.Quit() fires inside the callback before Dispose() is ever reached. The teardown is technically correct but effectively dead code in the CI happy path.

Comparison with AutoPilot (existing CI feature in the same plugin):
AutoPilot uses an async UniTask pattern — it awaits loading completion, runs its work, then quits in a finally block. This gives async operations (like network I/O) time to complete. The new LoadingTimes uses a synchronous event callback that calls Track() then Application.Quit() immediately — this is a critical difference that likely causes analytics data loss (see P1 finding below).

STEP 4 — Member audit

Member Consumers Assessment
LoadingTimeSampler.Sample() 1 (LoadingTimes.OnStageUpdated) Single-use static → merge into LoadingTimes
LoadingTimeSampler.ToJObject() 1 (LoadingTimes.OnStageUpdated) Single-use static → merge into LoadingTimes
StageMeasure (struct) 1 (LoadingTimeSampler) Could be private nested type after merge

STEP 5 — Line-level findings

See inline comments below. Summary of findings:

# Severity File Issue
1 P1 LoadingTimeSampler.cs:19-36 Missing upper-bound check on array access → IndexOutOfRangeException
2 P1 LoadingTimeSampler.cs:48-54 Iterates uninitialized array entries → ArgumentException on duplicate JObject keys
3 P1 LoadingTimes.cs:39-43 Application.Quit() immediately after Track() without Flush() → analytics data likely lost
4 P2 LoadingTimeSampler.cs:6 Entirely static mutable state — merge into LoadingTimes (CLAUDE.md §11)
5 P2 LoadingTimes.cs:11 Namespace–type name collision (DCL.LoadingTimes.LoadingTimes)

STEP 5B — Design smells

  • Branch name mismatch (ADR-6): Branch is fix/loading-benchmark-analytic but PR title correctly uses feat:. The branch prefix should match — feat/loading-benchmark-analytic.

STEP 6 — Complexity

COMPLEX — introduces new assembly reference, event subscription lifecycle in ProfilingPlugin, modifies plugin constructor signature (affects all callers), and changes the authentication/initialization flow.

STEP 7 — QA

YES — modifies runtime code under Explorer/ that ships in the build. The constructor signature change to ProfilingPlugin and the auth-flow change in RealUserInAppInitializationFlow run in all builds, not just when the flag is set.

STEP 8 — Non-blocking warnings

None.

Security review

No security issues found. The measure-loading-time flag requires command-line access (not a remote attack vector). Auth-screen skip mirrors the existing AUTOPILOT pattern. No secrets or PII exposed in the analytics payload.


REVIEW_RESULT: FAIL ❌
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Introduces new assembly reference, event subscription lifecycle in ProfilingPlugin, modifies plugin constructor signature, and changes auth/init flow.
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub

Comment thread Explorer/Assets/DCL/PerformanceAndDiagnostics/LoadingTimes/LoadingTimeSampler.cs Outdated
Comment thread Explorer/Assets/DCL/PerformanceAndDiagnostics/LoadingTimes/LoadingTimeSampler.cs Outdated
Comment thread Explorer/Assets/DCL/PerformanceAndDiagnostics/LoadingTimes/LoadingTimes.cs Outdated
Comment thread Explorer/Assets/DCL/PerformanceAndDiagnostics/LoadingTimes/LoadingTimeSampler.cs Outdated
Comment thread Explorer/Assets/DCL/PerformanceAndDiagnostics/LoadingTimes/LoadingTimes.cs Outdated
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

badge

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped
EditMode ✅ Passed 24460 0 13
PlayMode ✅ Passed 236 0 5

@lorenzo-ranciaffi lorenzo-ranciaffi added the clean-build Used to trigger clean build on PR label Aug 3, 2026

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #9494: feat: loading benchmark analytic (Re-review)

Previous review findings addressed: The author rewrote the static LoadingTimeSampler + LoadingTimes into a single instance-based LoadingTimeBenchmark, addressing all five prior findings (static mutable state, index-out-of-bounds, uninitialized array iteration, analytics delivery, namespace collision, and single-consumer merge). Well done.

STEP 2 — Root-cause check

PASS. This PR adds a new CI-only feature — a loading-time benchmark activated by the measure-loading-time app arg. It measures startup stage durations, reports them via analytics, and quits. This is genuine new functionality, not a symptom fix.

STEP 3 — Design & integration

PASS.

Owner search for LoadingTimeBenchmark:

  • Manages: observation of ILoadingStatus.CurrentStageMut stage transitions for timing measurement.
  • Existing owners searched: ILoadingStatus is created by the container infrastructure and consumed by the loading screen UI. Stage transitions are driven by RealUserInAppInitializationFlow and container initialization. No existing class measures and reports stage durations — this is a new cross-cutting CI instrumentation concern.
  • Conclusion: No existing owner can host this logic. ProfilingPlugin is the natural home for profiling/diagnostic tools, and conditional creation via the flag keeps it clean.

Teardown trace:

  • loadingStatus.CurrentStageMut.OnUpdate += OnStageUpdated (constructor, L50) → OnUpdate -= OnStageUpdated (Dispose(), L55) ✅
  • ProfilingPlugin.Dispose() calls loadingTimeBenchmark?.Dispose()
  • ReportAndQuitAsync() — detached via .Forget() with proper try/catch/finally per CLAUDE.md §9. However, UniTask.Delay has no cancellation token — if Dispose() runs during the grace period, the delay continues detached. See P2-1 below.

STEP 4 — Member audit

  • LoadingTimeBenchmark(ILoadingStatus, IAnalyticsController, IScenesCache) — 1 consumer (ProfilingPlugin constructor). ✅
  • Dispose() — 1 consumer (ProfilingPlugin.Dispose). ✅
  • All other members are private. No single-use public accessors. ✅

STEP 5 — Line-level findings

See inline comments. Summary:

# Sev Finding
1 P2 UniTask.Delay in ReportAndQuitAsync has no CancellationToken — delay continues if Dispose() runs during grace period
2 P2 Scene hash read across async boundary in ReportAndQuitAsync — capture synchronously before .Forget()
3 P2 Removed AnalyticsEvents XML comment warning about "Refresh Events" button — confirm this step is no longer required

Security: No issues found. The auth bypass mirrors the existing AUTOPILOT pattern with proper identity-expiry fallback (Application.Quit(1) if identity is null/expired). The analytics payload contains only timing floats and a scene identifier — no PII, no user-controlled input.

STEP 6 — Complexity

SIMPLE. New self-contained CI-only diagnostic class with event subscription and analytics reporting. No ECS systems, no complex async patterns, no cross-world access.

STEP 7 — QA

NO. The feature is gated behind a CI-only measure-loading-time app arg that is never passed in normal usage. The auth flow changes are purely additive flag checks that don't alter default behavior.

STEP 8 — Non-blocking warnings

None.

STEP 9 — Verdict

REVIEW_RESULT: PASS ✅
COMPLEXITY: SIMPLE
COMPLEXITY_REASON: New self-contained CI-only diagnostic class with event subscription and analytics reporting; no ECS, complex async, or architectural complexity.
QA_REQUIRED: NO


Reviewed by Jarvis 🤖 · Requested by lorenzo-ranciaffi via GitHub

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: feat: loading benchmark analytic (#9494)

STEP 2 — Root-cause check

PASS. This PR adds a new CI-only benchmarking feature — not fixing a bug. The approach (subscribe to loading stage events, measure wall-clock durations, report as a single analytics event, quit) directly addresses the stated goal.

STEP 3 — Design & integration

PASS. LoadingTimeBenchmark is a new long-lived unit managed by ProfilingPlugin.

Owner search:

  • ProfilingPlugin (PluginSystem/Global/ProfilingPlugin.cs): Already hosts the comparable AutoPilot CI tool. Construction and disposal lifecycle managed here. ✅
  • LoadingStatusAnalyticsDecorator (Analytics/DecoratorBased/LoadingStatusAnalyticsDecorator.cs): Existing decorator that tracks loading stages for Sentry spans and initial_loading events. The benchmark serves a different purpose (synthetic CI wall-clock timing + quit) — different output, different consumer (CI matrix vs Sentry). Separate class is justified.
  • Constructor-time creation (vs InjectToWorld for AutoPilot) is correct: the benchmark must subscribe to OnUpdate before any stages fire, while AutoPilot polls with await UniTask.Yield(). The class comment acknowledges this timing constraint.

Teardown trace:

  • OnUpdate += OnStageUpdated (ctor) → OnUpdate -= OnStageUpdated (Dispose()) ✅
  • new CancellationTokenSource() (field) → cts.SafeCancelAndDispose() (Dispose()) ✅
  • ProfilingPlugin.Dispose()loadingTimeBenchmark?.Dispose()

STEP 4 — Member audit

No new public properties or accessors beyond IDisposable.Dispose(). All logic is private. No concerns.

STEP 5 — Line-level findings

See inline comments. Two P2 findings — both are non-blocking improvements.

Note: The result.Success == false!result.Success cleanup (lines 234, 240) is an unrelated drive-by style fix. Harmless but out of scope for this PR.

Security review

No security issues found. The MEASURE_LOADING_TIME flag follows the same security model as the existing AUTOPILOT flag (CLI argv only, not reachable via decentraland:// deep links per DeepLinkAllowlist). Analytics payload contains only non-sensitive timing data and scene name. SafeCancelAndDispose() usage is correct.

STEP 6 — Complexity

COMPLEX — Modifies ProfilingPlugin constructor signature (DI change), adds new assembly reference, introduces new UniTaskVoid async flow.

STEP 7 — QA assessment

QA_REQUIRED: YES — Modifies runtime code paths in RealUserInAppInitializationFlow (auth bypass logic) and ProfilingPlugin (constructor signature change). While the benchmark is CI-only (gated behind CLI flag), the auth flow changes touch the initialization path.

STEP 8 — Non-blocking warnings

None.


REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies ProfilingPlugin constructor (DI), adds new assembly reference, introduces UniTaskVoid async flow
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by lorenzo-ranciaffi via GitHub

@lorenzo-ranciaffi lorenzo-ranciaffi removed no QA needed Used to tag pull requests that does not require QA validation clean-build Used to trigger clean build on PR labels Aug 4, 2026

@DafGreco DafGreco left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✔️ PR reviewed and approved by QA on both platforms following instructions playing both happy and un-happy path

Regressions for this ticket had been performed in order to verify that the normal flow is working as expected:

  • [✔️ ] Backpack and wearables in world
  • [✔️ ] Emotes in world and in backpack
  • [✔️ ] Teleport with map/coordinates/Jump In
  • [✔️ ] Chat and multiplayer
  • [✔️ ] Profile card
  • [ ✔️] Camera
  • [ ✔️] Skybox
  • [ ✔️] Settings

No new issues were found during this sanity check

@decentraland-bot

This comment has been minimized.

@decentraland-bot

This comment has been minimized.

@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9494, run #31180334944

Builds: Windows change, Windows baseline, macOS change, macOS baseline

How to read this table
  • Each build is measured 3 times. The values are the median, and (min–max) is the lowest and highest of those runs — a wide range means the metric is noisy and small differences are not trustworthy.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a real difference: larger than both 3% and the run-to-run range.
  • ⚪ within noise — the difference is smaller than how much the build varies between its own runs, so it cannot be told apart from random variation. Treat it as no change.
  • Exceptions per run — the average number of exceptions in a run's log; more than the baseline is flagged 🔴 even when frame times look fine. The Exception breakdown under each table groups them by the explorer's report category and exception type (as totals across the runs).
  • A run that logged unusually many exceptions (at least 10 and 5× the median of its build's runs — e.g. a service was down during it) is excluded from all numbers and called out under the table.

Apple M1

Metric Baseline Change Δ Result
Samples 4467 (×3) 4533 (×3)
CPU average 20.0 ms (20.0–20.4) 19.8 ms (19.7–20.1) -0.3 ms ⚪ within noise
CPU 1% worst 27.4 ms (26.7–28.4) 26.7 ms (26.5–27.0) -0.7 ms ⚪ within noise
CPU 0.1% worst 33.7 ms (31.6–48.5) 30.0 ms (30.0–36.4) -3.6 ms ⚪ within noise
GPU average 31.2 ms (31.0–33.4) 30.9 ms (30.0–31.6) -0.3 ms ⚪ within noise
GPU 1% worst 34.2 ms (34.0–35.0) 33.3 ms (33.3–34.2) -0.8 ms ⚪ within noise
GPU 0.1% worst 34.5 ms (34.3–35.8) 33.6 ms (33.6–34.7) -0.9 ms ⚪ within noise
Exceptions per run 0 0 0 ⚪ none new

@lorenzo-ranciaffi
lorenzo-ranciaffi enabled auto-merge (squash) August 10, 2026 08:07
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Windows and Mac build successful in Unity Cloud! You can find a link to the downloadable artifact below.

Name Link
Commit d53e0b7
Logs https://github.com/decentraland/unity-explorer/actions/runs/31377251050
Download Windows https://github.com/decentraland/unity-explorer/suites/85103767628/artifacts/9059441930
Download Windows S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/fix/loading-benchmark-analytic/pr-24763-d53e0b7/Decentraland_windows64.zip
Download Mac https://github.com/decentraland/unity-explorer/suites/85103767628/artifacts/9059240290
Download Mac S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/fix/loading-benchmark-analytic/pr-24763-d53e0b7/Decentraland_macos.zip
Built on 2026-08-10T10:38:45Z

Lint

Warnings count reduced: 13715 => 13714

Warnings/errors in files changed by this PR (9)
Assets/DCL/PerformanceAndDiagnostics/SentryTransactionManager.cs:197  ArrangeRedundantParentheses  Redundant parentheses
Assets/DCL/UserInAppInitializationFlow/RealUserInAppInitializationFlow.cs:380  InconsistentNaming  Name 'withUI' does not match rule 'parameters_should_be_camel_case'. Suggested name is 'withUi'.
Assets/DCL/PerformanceAndDiagnostics/LoadingTimes/LoadingTimeBenchmark.cs:11  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/PerformanceAndDiagnostics/SentryTransactionManager.cs:291  VariableCanBeNotNullable  'spanStack' can be declared as non-nullable
Assets/DCL/PerformanceAndDiagnostics/SentryTransactionManager.cs:338  VariableCanBeNotNullable  'spanStack' can be declared as non-nullable
Assets/DCL/PerformanceAndDiagnostics/SentryTransactionManager.cs:78  VariableCanBeNotNullable  'transaction' can be declared as non-nullable
Assets/DCL/PerformanceAndDiagnostics/SentryTransactionManager.cs:89  VariableCanBeNotNullable  'transaction' can be declared as non-nullable
Assets/DCL/PerformanceAndDiagnostics/SentryTransactionManager.cs:100  VariableCanBeNotNullable  'transaction' can be declared as non-nullable
Assets/DCL/UserInAppInitializationFlow/RealUserInAppInitializationFlow.cs:177  VariableHidesOuterVariable  Parameter 'ct' hides outer parameter with the same name

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped
EditMode ✅ Passed 24560 0 13
PlayMode ✅ Passed 236 0 5

@lorenzo-ranciaffi
lorenzo-ranciaffi merged commit a01cb67 into dev Aug 10, 2026
20 of 21 checks passed
@lorenzo-ranciaffi
lorenzo-ranciaffi deleted the fix/loading-benchmark-analytic branch August 10, 2026 12:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants