Skip to content

feat: add utc_offset_minutes to the analytics context - #10168

Merged
popuz merged 8 commits into
devfrom
chore/analytics/local-timezone
Sep 23, 2026
Merged

popuz merged 8 commits into
devfrom
chore/analytics/local-timezone

Conversation

@popuz

@popuz popuz commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Segment stores every timestamp in UTC. The client-sent timestamp is relabeled to originalTimestamp, Segment recomputes its own timestamp, and the offset in the ISO string never reaches the warehouse. The Unity client sends nothing about the user's time zone, so retention (D1/D7) can only be cut on UTC midnight. The Godot team measured the size of that artifact in godot-explorer#2763: D1 for the same cohort reads 24.75% for installs at 23:00 UTC and 4.20% for installs at 01:00 UTC.

Change

StaticCommonTraitsPlugin adds one common trait to the Segment context of every event:

Field Type Example Source
utc_offset_minutes integer 180 (UTC+3), 330 (India), -210 (Newfoundland) TimeZoneInfo.Local.GetUtcOffset(DateTime.UtcNow)

local = utc + offset. Local calendar day in SQL:

date(timestamp + utc_offset_minutes * interval '1 minute')
  • Minutes rather than hours so half- and quarter-hour zones (India, Nepal, Newfoundland, Chatham) round-trip exactly.
  • Computed once at startup and cached as a JToken, so the per-event cost is one reference assignment like the other static traits. A DST switch mid-session is a negligible error next to the current UTC-day cut; moving the trait to DynamicCommonTraitsPlugin later is a one-line change.
  • Lives in context, so in the warehouse it lands as context_utc_offset_minutes on tracks and every event table.

Why a signed integer, not a zone name

A zone name string depends on both the engine and the OS: Unity on Windows returns Windows zone ids such as Pacific Standard Time, Unity on macOS returns America/Los_Angeles; Godot gives IANA on Android/iOS/macOS and Windows ids on Windows. Emitting the same strings from every client would need a Windows→IANA mapping table and alias normalization. The offset is computed the same way by every engine on every OS, no mapping, and it is all a local-day cut needs.

Why utc_offset_minutes, not tz_offset_minutes / time_zone_offset_minutes

  • Matches ISO 8601 and the standard libraries on our platforms: .NET GetUtcOffset, Rust time::UtcOffset, Python utcoffset().
  • The name states the sign: the offset is from UTC to local. timezone offset is ambiguous because JavaScript's getTimezoneOffset() returns the inverted sign (-180 for UTC+3).
  • Says nothing about a zone, so nobody expects it to carry DST rules or a zone id.

Cross-client alignment is proposed in godot-explorer#2763 (comment): utc_offset_minutes (int) on launcher, unity-explorer and godot-explorer; the launcher's existing fp_timezone stays the only zone-name field.

Out of scope

  • IANA zone id. Deliberately not sent from Unity: it would need the mapping table above, and the launcher already emits fp_timezone on every event. Per the alignment proposal, fp_timezone stays the only zone-name field across clients.
  • Historical events cannot be backfilled.

Test

Manual: run the client with --debug analytics logging (or the DebugAnalyticsService) and confirm every tracked event carries utc_offset_minutes equal to the machine's current UTC offset in minutes.

🤖 Generated with Claude Code

Segment stores every timestamp in UTC and strips the offset from the
ISO string, so local-day retention (D1/D7) cannot be computed from the
Unity client's events. Send the system time zone offset in minutes as a
common context trait, resolved once at session start. The field name
matches the one the Godot client is adding (godot-explorer#2763).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@popuz
popuz requested review from a team as code owners September 18, 2026 20:18
@github-actions
github-actions Bot requested a review from anicalbano September 18, 2026 20:19
@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Windows and Mac built successfully in Unity Cloud.

Name Links & timing
Build 73f2c53 · Logs · built 2026-09-23T16:54:53Z
Windows GitHub job · Unity Cloud #7 · Unity log · ⏱ 38m 56s build + 6m 3s queue · Download .zip · .zip via S3
Mac GitHub job · Unity Cloud #7 · Unity log · ⏱ 43m 13s build + 3m 1s queue · Download .zip · .zip via S3

Lint

Warnings not reduced: 11846 => 11846 — remove at least 1 warning to merge.

No warnings in files changed by this PR — showing general ones you can remove to unblock (50 of 11846)
Assets/DCL/MarketplaceCredits/Purchase/UI/CreditPurchaseModalController.cs:685  ArrangeRedundantParentheses  Redundant parentheses
Assets/DCL/Rendering/GPUInstanceBatcher/ComputeShaders/DrawArgsInstanceCountTransfer.compute:1  CppUnusedIncludeDirective  Possibly unused #include directive
../render-features/AvatarOutline/RenderFeature_AvatarOutline.RenderPass_OutlineDraw.cs:75  RedundantArgumentDefaultValue  The parameter 'flags' has the same default value
Assets/DCL/Communities/CommunitiesCard/Events/EventListController.cs:156  RedundantArgumentDefaultValue  The parameter 'parcelToTeleport' has the same default value
Assets/DCL/Friends/UI/FriendPanel/Sections/Friends/FriendListSectionUtilities.cs:57  RedundantArgumentDefaultValue  The parameter 'parcelToTeleport' has the same default value
../render-features/ObjectHighlight/RenderFeature_ObjectHighlight.RenderPass_DrawObjects.cs:226  RedundantArgumentDefaultValue  The parameter 'shaderPassId' has the same default value
../render-features/ObjectHighlight/RenderFeature_ObjectHighlight.RenderPass_DrawObjects.cs:236  RedundantArgumentDefaultValue  The parameter 'shaderPassId' has the same default value
Assets/DCL/Chat/_Refactor/ChatReactions/Tests/LocalPlayerWorldReactorShould.cs:141  RedundantArgumentDefaultValue  The parameter 'walletId' has the same default value
Assets/DCL/Chat/_Refactor/ChatReactions/Tests/LocalPlayerWorldReactorShould.cs:156  RedundantArgumentDefaultValue  The parameter 'walletId' has the same default value
Assets/DCL/Chat/_Refactor/ChatReactions/Tests/LocalPlayerWorldReactorShould.cs:171  RedundantArgumentDefaultValue  The parameter 'walletId' has the same default value
Assets/DCL/MapRenderer/MapLayers/HomeMarker/HomeMarkerController.cs:135  RedundantArgumentDefaultValue  The parameter 'worldName' has the same default value
Assets/DCL/Infrastructure/ECS/Unity/Materials/Tests/CreateBasicMaterialSystemShould.cs:76  RedundantAssignment  The value passed to the method is never used because it is overwritten in the method body before being read
Assets/DCL/Infrastructure/ECS/Unity/Materials/Tests/CreatePBRMaterialSystemShould.cs:85  RedundantAssignment  The value passed to the method is never used because it is overwritten in the method body before being read
Assets/DCL/RealmNavigation/RetrieveSceneFromFixedRealm.cs:46  RedundantAssignment  The value passed to the method is never used because it is overwritten in the method body before being read
Assets/DCL/AvatarRendering/Emotes/Editor/EmbeddedEmotesEditor.cs:37  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/AvatarRendering/Emotes/Editor/EmbeddedEmotesEditor.cs:38  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Backpack/AvatarSection/Outfits/OutfitsPresenter.cs:193  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Chat/History/ChatHistoryEncryptor.cs:25  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/ECS/Unity/Materials/Systems/StartMaterialsLoadingSystem.cs:167  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/Global/Editor/DebugSettingsDrawer.cs:73  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/Global/Editor/RealmLaunchSettingsDrawer.cs:248  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/Utility/Primitives/BoxFactory.cs:64  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/Utility/Primitives/BoxFactory.cs:100  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/Utility/Primitives/BoxFactory.cs:136  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/Utility/Primitives/BoxFactory.cs:172  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/Utility/Primitives/BoxFactory.cs:219  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/Utility/Primitives/CylinderVariantsFactory.cs:137  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Landscape/Jobs/NoiseJob.cs:68  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/PluginSystem/Global/VoiceChatDebugContainer.cs:164  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/SDKComponents/AudioAnalysis/AudioAnalysisSystem.cs:75  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/SDKComponents/AvatarAttach/Systems/AvatarAttachHandlerSystem.cs:87  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Translation/Processors/AngleBracketSegmentationRule.cs:29  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Translation/Processors/AngleBracketSegmentationRule.cs:38  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Multiplayer/Connections/Archipelago/Rooms/ArchipelagoIslandRoom.cs:53  RedundantBaseConstructorCall  Redundant base constructor call
../render-features/SkyboxEnvironmentProbe/SkyboxToCubemapRendererPass.cs:259  RedundantCast  Type cast is redundant
../render-features/SkyboxEnvironmentProbe/SkyboxToCubemapRendererPass.cs:310  RedundantCast  Type cast is redundant
Assets/DCL/AvatarRendering/AvatarShape/Tests/EditMode/FinishAvatarMatricesCalculationSystemShould.cs:154  RedundantCast  Type cast is redundant
Assets/DCL/AvatarRendering/AvatarShape/Tests/EditMode/FinishAvatarMatricesCalculationSystemShould.cs:155  RedundantCast  Type cast is redundant
Assets/DCL/AvatarRendering/AvatarShape/Tests/EditMode/FinishAvatarMatricesCalculationSystemShould.cs:156  RedundantCast  Type cast is redundant
Assets/DCL/AvatarRendering/AvatarShape/Tests/EditMode/FinishAvatarMatricesCalculationSystemShould.cs:157  RedundantCast  Type cast is redundant
Assets/DCL/AvatarRendering/AvatarShape/Tests/EditMode/FinishAvatarMatricesCalculationSystemShould.cs:158  RedundantCast  Type cast is redundant
Assets/DCL/AvatarRendering/AvatarShape/Tests/EditMode/FinishAvatarMatricesCalculationSystemShould.cs:159  RedundantCast  Type cast is redundant
Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs:135  RedundantCast  Type cast is redundant
Assets/DCL/MarketplaceCredits/Purchase/Tests/CreditsTopUpServiceShould.cs:36  RedundantCast  Type cast is redundant
Assets/DCL/SDKComponents/AvatarAttach/Tests/AvatarAttachHandlerSystemShould.cs:588  RedundantCast  Type cast is redundant
Assets/DCL/SDKComponents/Billboard/Tests/BillboardTest.cs:173  RedundantCast  Type cast is redundant
Assets/DCL/SDKComponents/MediaStream/YouTube/HlsManifestBuilder.cs:191  RedundantCast  Type cast is redundant
Assets/DCL/SpringBones/SpringBoneService.cs:185  RedundantCast  Type cast is redundant
Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs:506  RedundantCast  Type cast is redundant
Assets/DCL/WebRequests/CustomDownloadHandlers/PartialDownloadHandler.cs:26  RedundantCast  Type cast is redundant

Lint run · took 33m 58s

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped Tests time Job time
EditMode ✅ Passed 26014 0 13 3m 47s 20m 17s
PlayMode ✅ Passed 256 0 37 42s 11m 29s

Tests time sums the test cases; Job time is the job's wall clock including checkout, licensing and asset import.

Slowest tests
  • [editmode] 16.5s DCL.AuthenticationScreenFlow.Tests.ProfileFetchingAuthStateShould.CancelStalledFetchOnTimeout
  • [editmode] 10.2s DCL.Tests.Editor.ValidationTests.CheckForDebugUsage
  • [editmode] 10.0s DCL.Notifications.Tests.NotificationsRequestControllerShould.ReuseSingleListInstanceAcrossPollIterations
  • [editmode] 9.9s DCL.Tests.Editor.ValidationTests.CheckUnityObjectsForMissingReferences
  • [editmode] 5.0s DCL.Friends.Tests.FriendsConnectivityStatusTrackerShould.RaiseOnlineEventWhenSameStatusIsRebroadcastAfterReset
  • [editmode] 5.0s CrdtEcsBridge.WorldSynchronizer.Tests.CrdtWorldSynchronizerShould.ThrowIfSyncBufferIsAlreadyRented
  • [editmode] 4.6s DCL.Tests.Editor.ValidationTests.SettingsAreValid
  • [editmode] 4.3s SceneRunner.Tests.SceneFacadeShould.ContinueUpdateLoopOnBackgroundThread(20,4000)
  • [editmode] 4.2s SceneRunner.Tests.SceneFacadeShould.ContinueUpdateLoopOnBackgroundThread(90,4000)
  • [editmode] 4.1s SceneRunner.Tests.SceneFacadeShould.ContinueUpdateLoopOnBackgroundThread(10,4000)
  • [playmode] 3.1s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.ContinuousTweensRunIndefinitelyWhenDurationIsZero
  • [playmode] 2.5s Global.Tests.PlayMode.CubeWaveSceneShould.EmitECSComponents
  • [playmode] 2.4s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TextureMoveSequenceUpdatesMaterial
  • [playmode] 2.3s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.RotateContinuousCompletesAfterDuration
  • [playmode] 2.3s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithoutLoopCompletesOnce
  • [playmode] 2.3s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.TextureMoveContinuousOffsetCompletesAndUpdatesMaterial
  • [playmode] 2.3s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.MoveContinuousMovesAndCompletesAfterDuration
  • [playmode] 1.6s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithMoveRotateScaleWithOmittedScale_ResolvesScaleFromCurrentTransform
  • [playmode] 1.6s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.RotateContinuousPositiveAndNegativeYDirectionsAreOpposite
  • [playmode] 1.6s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithRotateAndMove

Full report: run summary · results + editor logs: editmode · playmode

Performance

🏁 Bare-metal benchmark finished — run #35892111812.

Full report

PR #10168, run #35892111812

Overall: ✅ no significant changes

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

How to read this table
  • Each build is measured 3 times, interleaved with the other build (change, baseline, change, baseline, ...) in the same session, so both see the same world content and machine state. The values are the median, and (min–max) is the lowest and highest of those runs.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a difference that passed every check: the runs are fully separated (every run of one build faster than every run of the other), and the median difference is at least 3% and at least 0.5 ms.
  • ⚪ within noise — the builds' runs overlap, or the difference is tiny; it cannot be told apart from random variation. Treat it as no change.
  • — informational — the 0.1% worst metrics average only the few worst frames of a run, so a single OS hiccup swings them by a lot; they are shown for context and never earn a verdict.
  • ⚠️ no verdict — the two builds' sessions were not comparable (very different sample counts, or too few usable runs), so no conclusion is drawn from them.
  • Exceptions per run — the average number of exceptions in a run's log, not counting teardown ones logged while the app quits. Flagged only on a difference of at least 2 per run and 1.5× the other build; exception kinds the baseline never threw are called out under the table. The Exception breakdown groups all of 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.
  • The Overall line at the top only reacts to a metric that moved on two or more machines, or by 10% or more on one — a single modest 🟢/🔴 cell can still be a statistical fluke.

Apple M1

Metric Baseline Change Δ Result
Samples 4179 (×3) 4208 (×3)
CPU average 21.4 ms (20.7–21.5) 21.2 ms (21.0–21.8) -0.1 ms ⚪ within noise
CPU 1% worst 223.3 ms (191.8–226.1) 208.9 ms (202.5–230.4) -14.4 ms ⚪ within noise
CPU 0.1% worst 234.8 ms (231.9–236.4) 235.5 ms (234.6–236.7) 0.7 ms — informational
GPU average 34.2 ms (33.8–34.4) 34.1 ms (33.6–34.5) -0.1 ms ⚪ within noise
GPU 1% worst 45.3 ms (44.3–45.6) 45.2 ms (44.8–45.6) -0.1 ms ⚪ within noise
GPU 0.1% worst 46.4 ms (45.3–46.7) 46.6 ms (46.0–46.6) 0.2 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

Intel Core i5

Metric Baseline Change Δ Result
Samples 4283 (×3) 4450 (×3)
CPU average 20.5 ms (20.4–21.1) 19.9 ms (19.9–21.3) -0.6 ms ⚪ within noise
CPU 1% worst 576.5 ms (540.4–623.8) 551.9 ms (512.0–640.2) -24.6 ms ⚪ within noise
CPU 0.1% worst 844.2 ms (810.2–853.6) 759.4 ms (758.0–889.7) -84.8 ms — informational
GPU average 11.3 ms (11.3–11.6) 11.8 ms (11.6–11.9) 0.4 ms ⚪ within noise
GPU 1% worst 214.5 ms (211.8–240.5) 274.0 ms (240.3–301.3) 59.5 ms ⚪ within noise
GPU 0.1% worst 816.2 ms (698.1–846.5) 761.7 ms (690.9–874.9) -54.5 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

Automation

On demand — comment /visual-tests on this PR to run the visual regression suite against its build.

The Godot client has not shipped its field yet, so nothing is locked in
downstream. The name spells out what the number is relative to.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@popuz popuz changed the title feat: add tz_offset_minutes to the analytics context feat: add utc_offset_minutes to the analytics context Sep 18, 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.

Review — PR #10168: feat: add tz_offset_minutes to the analytics context

Step 2 — Root-cause check: PASS

The problem is clearly stated: the Segment pipeline loses timezone information because the Unity client sends nothing about the user's timezone, making D1/D7 retention metrics inaccurate when cut on UTC midnight. The fix adds utc_offset_minutes to every tracked event via the existing StaticCommonTraitsPlugin. This addresses the root cause directly — the missing data field.

Step 3 — Design & integration: PASS

Placement. StaticCommonTraitsPlugin is the correct home. The field follows the exact initialization pattern of its siblings (os, dclRendererType) — a readonly JToken resolved once via a field initializer and assigned per-event in Track() at the cost of one reference copy. No new type or lifecycle unit is introduced.

Static vs Dynamic. Evaluated whether the offset belongs in DynamicCommonTraitsPlugin given that a DST transition mid-session would stale the cached value. Downgraded: TimeZoneInfo.Local on Unity's Mono runtime is itself resolved once per process — calling GetUtcOffset per-event in DynamicCommonTraitsPlugin would return the same value. The author documented this trade-off and the one-line migration path. The retention use-case (calendar-day bucketing) is tolerant of a ±1 hour edge case occurring at most twice per year.

No new lifecycle unit. Nothing to trace through the mandatory owner search — the change adds one field to an existing plugin, no subscriptions, connections, or teardown needed.

Step 4 — Member audit: PASS

No new public surface. utcOffsetMinutes is private readonly, consumed only by Track(). No members to audit for single-use, absent-≠-false, or redundant-guard concerns.

Step 5 — Line-level review

R1 (alloc in hot paths): Clean — Track() assigns a cached JToken reference, no allocation.
R2 (LINQ): N/A.
R4 (ECS discipline): N/A — not an ECS system.
R5 (Entity by-ref): N/A.
R6 (acquire/release): N/A — no subscriptions, disposables, or pool rentals introduced.
R7 (nullability): Clean — non-nullable JToken field unconditionally assigned.
R8 (root-cause): PASS — see Step 2.
R9 (logging): N/A.
R11 (async/CT): N/A.
R12 (single-impl abstraction): N/A.
R13 (reuse/centralize): Clean — no duplicate constant or URL literal. TimeZoneInfo has no project wrapper.
R14 (contract honesty): Clean — see Step 3 rationale.
R15 (dead weight): N/A.
R16/R17 (naming): Clean — field utcOffsetMinutes follows camelCase; JSON key "utc_offset_minutes" follows the snake_case convention of all sibling keys.
R18 (magic numbers): N/A.
R19 (file hygiene): Clean.
R20 (idiom cluster): Clean — readonly on the new field; no concurrent collections.
R23 (AI comments): Clean — no comments added.
R24 (scope): Clean — one file, one feature, no unrelated changes.

Cast safety. (int)TimeZoneInfo.Local.GetUtcOffset(DateTime.UtcNow).TotalMinutes — real-world UTC offsets range from −720 to +840 minutes, always in whole-minute increments. TotalMinutes returns an integer-valued double for these inputs; the (int) truncation is lossless and well within int range.

using System; ordering. Correctly placed alphabetically: Newtonsoft.Json.Linq < System < UnityEngine.

Findings

# File Line Sev Rule Finding
1 StaticCommonTraitsPlugin.cs 24 P2 — Cross-client field name alignment. PR description says the name matches the Godot client's tz_offset_minutes (godot-explorer#2763), but the code now ships utc_offset_minutes after the rename commit (107137d). Confirm the Godot PR was also renamed, or update the PR description — mismatched names would break the joinability promise.
2 StaticCommonTraitsPlugin.cs — P2 R22 No test added. StaticCommonTraitsPlugin has zero pre-existing test coverage (confirmed via repo search). The change is pattern-identical to 6 other untested fields, so the gap is pre-existing — but R22 asks new behavior to ship with tests. A simple unit test constructing the plugin and asserting Track() populates "utc_offset_minutes" with a sane integer would close this.

Step 6 — Complexity: SIMPLE

One file, 3 added lines, no ECS/async/plugin/DI/networking changes. Pure data-field addition following an established pattern.

Step 7 — QA assessment: YES

The change modifies runtime code under Explorer/ that ships in the build (analytics payload). Verifiable via --debug analytics logging as described in the PR.

Step 8 — Non-blocking warnings

None. Main scene not modified.

Merge gates

R25 — Open threads: 0 unresolved review threads (first review round).
R26 — QA gate: Awaiting QA. PR carries manual test instructions.

Lint pre-flight

scripts/lint/custom-rules.sh is not present on this branch. Machine-held rules (R1–R24 regex/analyzer IDs) were checked by hand against the diff — no violations found.


REVIEW_RESULT: PASS ✅
COMPLEXITY: SIMPLE
COMPLEXITY_REASON: Single data-field addition to an existing analytics plugin — no ECS, async, DI, or networking changes
QA_REQUIRED: YES


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

@popuz popuz added the no QA needed Used to tag pull requests that does not require QA validation label Sep 21, 2026
@popuz

popuz commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

checked manually - new field is presented in the context
image

leanmendoza added a commit to decentraland/godot-explorer that referenced this pull request Sep 22, 2026
Segment stores every timestamp in UTC and recomputes its own `timestamp`, so
the offset in the client's ISO string never reaches the warehouse and retention
can only be cut on UTC midnight. #2763 measured the artifact: D1 for the same
cohort reads 24.75% for installs at 23:00 UTC and 4.20% for installs at 01:00.

Adds one signed integer to the common explorer fields, so it rides into the
`properties` of every event through the existing builder — including
`Install Attribution`, which fires pre-login. `local = utc + offset`; minutes
rather than hours so half- and quarter-hour zones round-trip exactly. Resolved
once at startup, like `renderer_version`.

The field name and the decision to send no zone name come from
decentraland/unity-explorer#10168 and popuz's comment on #2763, and supersede
the `tz_id` + `tz_offset_minutes` shape in the issue body.

The value is NOT `Time.get_time_zone_from_system()["bias"]`. Its sign is right,
but `OS_Unix::get_time_zone_info()` converts `strftime("%z")` with `bias % 100`,
which is negative in C, and the `bias < 0` branch subtracts it — double-negating
the minutes. Against the 4.6.2 binary we ship it reports -90 for Newfoundland
(-150) and -510 for the Marquesas (-570), while Kolkata, Kathmandu and Chatham
are fine. Newfoundland is one of the zones the minutes decision exists for, so
the offset is derived from the OS wall clock instead: read local as if it were
UTC, diff against real UTC. `chrono::Local` is also unusable here — it resolves
the zone from /etc/localtime, which Android does not maintain.
@popuz
popuz merged commit 1001f8f into dev Sep 23, 2026
29 of 31 checks passed
@popuz
popuz deleted the chore/analytics/local-timezone branch September 23, 2026 20:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no QA needed Used to tag pull requests that does not require QA validation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants