fix: use monotonic clock for intervals and durations, UTC for diagnostic timestamps - #339
Conversation
…tic timestamps Co-Authored-By: tanderson@launchdarkly.com <tanderson@launchdarkly.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
|
@cursor review |
…t RuntimeInformation Co-Authored-By: tanderson@launchdarkly.com <tanderson@launchdarkly.com>
…elper Co-Authored-By: tanderson@launchdarkly.com <tanderson@launchdarkly.com>
Co-Authored-By: tanderson@launchdarkly.com <tanderson@launchdarkly.com>
Co-Authored-By: tanderson@launchdarkly.com <tanderson@launchdarkly.com>
Record a stream init from a single reading of the elapsed timer and the wall clock, so time spent inside AddStreamInit is attributed to the next window rather than lost. IDiagnosticStore.DataSince is now documented and implemented as a UTC time; an external implementation returning a local-kind value is compared against DateTime.UtcNow in EventProcessor. Co-Authored-By: tanderson@launchdarkly.com <tanderson@launchdarkly.com>
| await dataSource.Start(); | ||
|
|
||
| Assert.False(receivedFailed.ExpectValue()); | ||
| var streamInit = received.ExpectValue(); |
There was a problem hiding this comment.
Should we update the client and FDv2 with these new asserts?
There was a problem hiding this comment.
Client: yes, done in 502beb6 — same two asserts on StreamInitDiagnosticRecordedOnOpen. It runs against a real HttpServer, so the duration is reliably non-zero.
FDv2: deliberately not, because the assert would be flaky rather than protective. FDv2StreamingDataSource.Start() sets _esStarted/_esTimer inside a Task.Run, while FDv2StreamingDataSourceTest calls dataSource.Start() and then _mockEventSource.TriggerOpen() synchronously on the test thread. Nothing orders those, so RecordStreamInit can legitimately observe an unstarted stopwatch (Elapsed == 0) and a default _esStarted (Kind == Unspecified). In production the ordering is guaranteed — the real event source only raises Open from inside StartAsync(), which runs after those two lines in the same Task.Run — so it's a test-harness race, not a product bug.
The three Start() methods share the fix, and the server + client tests both fail if _esTimer.Restart() is removed (verified by commenting it out), so the FDv2 path isn't uncovered in practice. Happy to add it too if you'd rather have the symmetry — it'd want the mock to signal when StartAsync has been entered instead of the existing Thread.Sleep.
Co-Authored-By: tanderson@launchdarkly.com <tanderson@launchdarkly.com>
There was a problem hiding this comment.
Should we call out this should also be UTC?
There was a problem hiding this comment.
Yes — done in f5917dd: "The UTC time at which the stream began attempted initialization." All three call sites now pass DateTime.UtcNow, and it lands in streamInits[].timestamp through UnixMillisecondTime.FromDateTime, which subtracts the UTC epoch without converting the kind, so UTC is the only correct input.
Co-Authored-By: tanderson@launchdarkly.com <tanderson@launchdarkly.com>
jsonbailey
left a comment
There was a problem hiding this comment.
Would calling dateTime.ToUniversalTime() in the CommonSdk fix all callers? The trade-off is that Unspecified kind would then be treated as local.
It would cover all callers, but the one caller it would newly apply to is exactly an After this PR there are only four production callers of That value feeds Packaging is the other consideration: If you want the defense-in-depth version, I'd order it the other way: make the inputs unambiguous first ( |
🤖 I have created a release *beep* *boop* --- ## [3.10.1](LaunchDarkly.InternalSdk-v3.10.0...LaunchDarkly.InternalSdk-v3.10.1) (2026-09-02) ### Bug Fixes * use monotonic clock for intervals and durations, UTC for diagnostic timestamps ([#339](#339)) ([e12ee4a](e12ee4a)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > **Release Please** cut for **LaunchDarkly.InternalSdk 3.10.1**, bumping `pkgs/shared/internal` from **3.10.0** to **3.10.1** in the manifest, `.csproj`, `CHANGELOG.md`, and `PROVENANCE.md`. > > The published patch includes the bug fix from [#339](#339): **monotonic clock** for intervals and durations, and **UTC** for diagnostic timestamps—reducing skew when system wall clock changes affect SDK timing and diagnostics. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 6c7fa36. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## Summary #339 fixed the monotonic-clock and UTC-timestamp bugs in `LaunchDarkly.InternalSdk`, released as 3.10.1 (#341), but neither SDK consumes it: both reference `LaunchDarkly.InternalSdk` by `PackageReference` outside `DebugLocalReferences`, so the fix reaches customers only once these pins move. ```diff - <PackageReference Include="LaunchDarkly.InternalSdk" Version="3.10.0" /> pkgs/sdk/server + <PackageReference Include="LaunchDarkly.InternalSdk" Version="3.10.1" /> - <PackageReference Include="LaunchDarkly.InternalSdk" Version="3.7.0" /> pkgs/sdk/client + <PackageReference Include="LaunchDarkly.InternalSdk" Version="3.10.1" /> ``` Two commits, because the two pins are not equivalent moves: - **Server (`fix:`)** — 3.10.0 -> 3.10.1, the patch itself and nothing else. - **Client (`feat:`)** — the pin was three minors behind, so this also picks up 3.8.0 (net8.0 target framework update), 3.9.0 (anonymous context attribute redaction in all events), and 3.10.0 (gzip `AutomaticDecompression` on the default HTTP handler). The last one changes runtime behavior for every client SDK user, which is why this commit is a `feat:` rather than folding into the pending 5.9.7 patch. Say so if you'd rather land it as a patch and I'll retype the commit. Verified against the published 3.10.1 (not a project reference — `project.assets.json` resolves `LaunchDarkly.InternalSdk/3.10.1` in both `src/obj`): server 1605 tests and client 380 tests pass, server builds for `netstandard2.0;net462;net8.0`, client for `netstandard2.0;net8.0`. Link to Devin session: https://app.devin.ai/sessions/c9a6a48cc2b44607804829d203d5bd60 Open in Devin Desktop: https://app.devin.ai/desktop/session/c9a6a48cc2b44607804829d203d5bd60?variant=devin Requested by: @tanderson-ld <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > Updates **PackageReference** pins for `LaunchDarkly.InternalSdk` so shipped NuGet builds pick up the **3.10.1** patch (monotonic-clock and UTC-timestamp fixes) instead of older locked versions. > > **Server SDK** moves **3.10.0 → 3.10.1** only—a straight patch bump. > > **Client SDK** moves **3.7.0 → 3.10.1**, so it also absorbs intermediate internal releases (e.g. anonymous context redaction in events and gzip `AutomaticDecompression` on the default HTTP handler), not just the 3.10.1 clock fix. `DebugLocalReferences` project references are unchanged. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 6a4d010. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
🤖 I have created a release *beep* *boop* --- ## [8.16.1](LaunchDarkly.ServerSdk-v8.16.0...LaunchDarkly.ServerSdk-v8.16.1) (2026-09-02) ### Bug Fixes * bump LaunchDarkly.InternalSdk pins to 3.10.1 ([#343](#343)) ([fa7bcf9](fa7bcf9)) * Retry after partial file reads. ([#218](#218)) ([93f4508](93f4508)) * use monotonic clock for intervals and durations, UTC for diagnostic timestamps ([#339](#339)) ([e12ee4a](e12ee4a)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > **Release Please** bumps **LaunchDarkly.ServerSdk** from **8.16.0** to **8.16.1** in the manifest, `LaunchDarkly.ServerSdk.csproj`, provenance docs, and changelog. > > This PR does not change SDK source; it cuts the **8.16.1** patch release and documents three bug fixes already on `main`: **LaunchDarkly.InternalSdk** pinned to **3.10.1**, retries after **partial file reads** (file data source), and **monotonic clock** for intervals/durations plus **UTC** for diagnostic timestamps. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fd96aee. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
🤖 I have created a release *beep* *boop* --- ## [5.10.0](LaunchDarkly.ClientSdk-v5.9.6...LaunchDarkly.ClientSdk-v5.10.0) (2026-09-02) ### Features * bump LaunchDarkly.InternalSdk pins to 3.10.1 ([fa7bcf9](fa7bcf9)) ### Bug Fixes * use monotonic clock for intervals and durations, UTC for diagnostic timestamps ([#339](#339)) ([e12ee4a](e12ee4a)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > **Release Please** cuts **LaunchDarkly.ClientSdk `5.10.0`** (from `5.9.6`) by updating the package version in `LaunchDarkly.ClientSdk.csproj`, `.release-please-manifest.json`, `PROVENANCE.md`, and adding the `5.10.0` section to `CHANGELOG.md`. > > This release packages work already on `main`: **`LaunchDarkly.InternalSdk` is pinned to `3.10.1`**, and **timing behavior** uses a **monotonic clock for intervals/durations** while **diagnostic timestamps use UTC** ([#339](#339)). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8bdb4c8. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [LaunchDarkly.ServerSdk](https://github.com/launchdarkly/dotnet-core) | `8.16.0` → `8.16.1` |  |  | --- ### Release Notes <details> <summary>launchdarkly/dotnet-core (LaunchDarkly.ServerSdk)</summary> ### [`v8.16.1`](https://github.com/launchdarkly/dotnet-core/releases/tag/LaunchDarkly.ServerSdk-v8.16.1): LaunchDarkly.ServerSdk: v8.16.1 [Compare Source](launchdarkly/dotnet-core@LaunchDarkly.ServerSdk-v8.16.0...LaunchDarkly.ServerSdk-v8.16.1) ##### Bug Fixes - bump LaunchDarkly.InternalSdk pins to 3.10.1 ([#​343](launchdarkly/dotnet-core#343)) ([fa7bcf9](launchdarkly/dotnet-core@fa7bcf9)) - Retry after partial file reads. ([#​218](launchdarkly/dotnet-core#218)) ([93f4508](launchdarkly/dotnet-core@93f4508)) - use monotonic clock for intervals and durations, UTC for diagnostic timestamps ([#​339](launchdarkly/dotnet-core#339)) ([e12ee4a](launchdarkly/dotnet-core@e12ee4a)) *** This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). <!-- CURSOR_SUMMARY --> </details> --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or PR is renamed to start with "rebase!". 👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired. --- This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
Summary
Repeating tasks (polling, event flush, connectivity checks) and stream-init durations were computed from the local wall clock, so a DST transition or NTP step during a task body skewed the next interval — a forward jump made the next poll fire immediately, a backward jump added the offset. Diagnostic timestamps were also built from
DateTime.Nowbut serialized as if UTC, socreationDate/dataSinceDate/stream-inittimestampwere wrong by the local UTC offset for every non-UTC host.TaskExecutor.StartRepeatingTasknow measures the task body with aStopwatch:interval - timer.Elapsedinstead ofnextTime.Subtract(DateTime.Now).timestampstays a wall-clock value but is nowDateTime.UtcNow. This also fixes a schema violation:stream-init.jsondeclaresdurationMilliswith"minimum": 0, and a backward clock step between the twoDateTime.Nowreads emitted a negative duration.DiagnosticStoreBaseand theEventProcessordiagnostic-window delay useDateTime.UtcNow, fixing off-by-UTC-offset diagnostic timestamps.LaunchDarkly.InternalSdkbyPackageReference(server3.10.0, client3.7.0), not by project reference, so a follow-up must bump those pins after InternalSdk is released. The client pin is three minors behind and worth bringing current at the same time.IDiagnosticStore.DataSinceis public and its documented contract changes local -> UTC. The clamp inEventProcessorbounds the impact on a third-party implementation returning local time to one diagnostic event fired early or one interval late.Implementation details
Root cause:
DateTime.Nowis a local wall clock. It can jump forwards or backwards between two readings without corresponding real time passing (DST, manual change, NTP step). Every interval must therefore come from a monotonic source; only values communicated outside the process (event timestamps,dataSinceDate) should come from a wall clock, and those should be UTC becauseUnixMillisecondTime.FromDateTimesubtracts the UTC epoch without converting the kind.Elapsed time is measured with
Stopwatchat each site rather than through a shared helper;Stopwatch.GetElapsedTimeis unavailable onnetstandard2.0/net462, andStopwatch.Elapsedneeds no conversion. The SDK packages build against the publishedLaunchDarkly.InternalSdk, so they could not use a new type there anyway.Wall clock intentionally retained:
AddStreamInit'stimestampargument,DataSince, and diagnosticcreationDateare payload values, so they stayDateTime— now UTC.DataSince's doc comment was updated to state it is UTC. Fully monotonic diagnostic-window math would require anIDiagnosticStoreinterface change; with UTC the remaining exposure is an NTP step, still bounded by the existing clamp.Testing:
DiagnosticStoreBaseTest.TimestampsAreUtcassertsDataSince.KindisUtcon construction and after a reset; it fails onmain, where the kind isLocal, and passes here.StreamingDataSourceTest.StreamInitDiagnosticRecordedOnOpennow asserts the recorded timestamp is UTC-kind and the duration is greater than zero. The existing stream-init tests matched the duration withIt.IsAny<TimeSpan>(), so deleting_esTimer.Restart()kept them green; with this assertion that mutation fails (verified). An unstartedStopwatchreportsTimeSpan.Zero, which looks plausible on the wire, where main's uninitializedDateTime.MinValueproduced an obviously absurd duration — so the timer needs its own assertion.StreamInitDiagnosticRecordedOnOpencarries the same two asserts. The FDv2 test does not:FDv2StreamingDataSource.Start()initializes the timer inside aTask.Runwhile the test triggersOpen/Errorsynchronously, so the assert would be racy there — a test-harness ordering gap, since the real event source only raisesOpenfrom withinStartAsync().EventProcessorTest's debug-expiry tests built their server time fromDateTime.Nowand fed it throughUnixMillisecondTime.FromDateTime— the same defect this PR fixes, and the reason the assembly was time-zone sensitive. They now useDateTime.UtcNow, which is also what a real HTTPDateheader carries.TaskExecutoris a larger API change than this fix warrants. The interval path no longer reads any wall clock, and the existingTaskExecutorTestcases cover its scheduling behavior.TaskExecutor/DiagnosticStoreBase/EventProcessorfixes in either direction;LaunchDarkly.InternalSdk.Testsis the coverage for those.How to test:
dotnet testforLaunchDarkly.InternalSdk.Tests,LaunchDarkly.ServerSdk.Tests, andLaunchDarkly.ClientSdk.Tests(net8.0) — all pass locally; the internal SDK also builds fornetstandard2.0;net462;net8.0. Backend/library change only, so no screenshots or staging preview apply.This comes out of a cross-SDK clock monotonicity audit; the same bug shape exists in the Ruby and Python SDKs' repeating-task helpers and in the legacy
dotnet-server-sdk/dotnet-client-sdkrepos.Link to Devin session: https://app.devin.ai/sessions/c9a6a48cc2b44607804829d203d5bd60
Open in Devin Desktop: https://app.devin.ai/desktop/session/c9a6a48cc2b44607804829d203d5bd60?variant=devin
Requested by: @tanderson-ld
Note
Overview
Fixes wall-clock skew in repeating tasks and diagnostics by switching interval/duration math to
Stopwatchand diagnostic wall times toDateTime.UtcNow.Repeating work (
TaskExecutor.StartRepeatingTask): the wait until the next run is nowinterval - elapsedfrom a stopwatch instead of comparing twoDateTime.Nowvalues, so DST/NTP jumps no longer cause immediate or delayed polls.Stream-init diagnostics (client/server/FDv2
StreamingDataSource): init duration is measured monotonically; the recorded timestamp is UTC. That avoids negativedurationMillison backward clock steps and aligns timestamps with how they are serialized.Diagnostic store / event processor:
DiagnosticStoreBaseinit and periodic events,EventProcessordiagnostic timer delay, andIDiagnosticStore.DataSince/AddStreamInitdocs now treat diagnostic times as UTC (fixing off-by-local-offset increationDate/dataSinceDate).Tests assert UTC
DateTimeKindon stream-init andDataSince, and that stream-init duration is > 0 on successful open.Reviewed by Cursor Bugbot for commit f5917dd. Bugbot is set up for automated code reviews on this repo. Configure here.