Skip to content

fix: use monotonic clock for intervals and durations, UTC for diagnostic timestamps - #339

Merged
tanderson-ld merged 8 commits into
mainfrom
devin/1788296418-monotonic-clocks
Sep 2, 2026
Merged

fix: use monotonic clock for intervals and durations, UTC for diagnostic timestamps#339
tanderson-ld merged 8 commits into
mainfrom
devin/1788296418-monotonic-clocks

Conversation

@tanderson-ld

@tanderson-ld tanderson-ld commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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.Now but serialized as if UTC, so creationDate/dataSinceDate/stream-init timestamp were wrong by the local UTC offset for every non-UTC host.

  • TaskExecutor.StartRepeatingTask now measures the task body with a Stopwatch: interval - timer.Elapsed instead of nextTime.Subtract(DateTime.Now).
  • Stream-init duration (server, FDv2, client) measured monotonically; the reported timestamp stays a wall-clock value but is now DateTime.UtcNow. This also fixes a schema violation: stream-init.json declares durationMillis with "minimum": 0, and a backward clock step between the two DateTime.Now reads emitted a negative duration.
  • DiagnosticStoreBase and the EventProcessor diagnostic-window delay use DateTime.UtcNow, fixing off-by-UTC-offset diagnostic timestamps.
  • Does not ship on its own. Both SDKs consume LaunchDarkly.InternalSdk by PackageReference (server 3.10.0, client 3.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.DataSince is public and its documented contract changes local -> UTC. The clamp in EventProcessor bounds 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.Now is 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 because UnixMillisecondTime.FromDateTime subtracts the UTC epoch without converting the kind.

Elapsed time is measured with Stopwatch at each site rather than through a shared helper; Stopwatch.GetElapsedTime is unavailable on netstandard2.0/net462, and Stopwatch.Elapsed needs no conversion. The SDK packages build against the published LaunchDarkly.InternalSdk, so they could not use a new type there anyway.

Wall clock intentionally retained: AddStreamInit's timestamp argument, DataSince, and diagnostic creationDate are payload values, so they stay DateTime — now UTC. DataSince's doc comment was updated to state it is UTC. Fully monotonic diagnostic-window math would require an IDiagnosticStore interface change; with UTC the remaining exposure is an NTP step, still bounded by the existing clamp.

Testing:

  • DiagnosticStoreBaseTest.TimestampsAreUtc asserts DataSince.Kind is Utc on construction and after a reset; it fails on main, where the kind is Local, and passes here.
  • StreamingDataSourceTest.StreamInitDiagnosticRecordedOnOpen now asserts the recorded timestamp is UTC-kind and the duration is greater than zero. The existing stream-init tests matched the duration with It.IsAny<TimeSpan>(), so deleting _esTimer.Restart() kept them green; with this assertion that mutation fails (verified). An unstarted Stopwatch reports TimeSpan.Zero, which looks plausible on the wire, where main's uninitialized DateTime.MinValue produced an obviously absurd duration — so the timer needs its own assertion.
  • The client's StreamInitDiagnosticRecordedOnOpen carries the same two asserts. The FDv2 test does not: FDv2StreamingDataSource.Start() initializes the timer inside a Task.Run while the test triggers Open/Error synchronously, so the assert would be racy there — a test-harness ordering gap, since the real event source only raises Open from within StartAsync().
  • EventProcessorTest's debug-expiry tests built their server time from DateTime.Now and fed it through UnixMillisecondTime.FromDateTime — the same defect this PR fixes, and the reason the assembly was time-zone sensitive. They now use DateTime.UtcNow, which is also what a real HTTP Date header carries.
  • No test asserts the repeating-task interval across a clock jump. Observing that requires mutating the process time zone, which is global state and would force test parallelization off for the whole assembly; an injectable time source in TaskExecutor is a larger API change than this fix warrants. The interval path no longer reads any wall clock, and the existing TaskExecutorTest cases cover its scheduling behavior.
  • Because the SDKs build against the published InternalSdk (see above), the server and client suites do not exercise the TaskExecutor/DiagnosticStoreBase/EventProcessor fixes in either direction; LaunchDarkly.InternalSdk.Tests is the coverage for those.

How to test: dotnet test for LaunchDarkly.InternalSdk.Tests, LaunchDarkly.ServerSdk.Tests, and LaunchDarkly.ClientSdk.Tests (net8.0) — all pass locally; the internal SDK also builds for netstandard2.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-sdk repos.

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 Stopwatch and diagnostic wall times to DateTime.UtcNow.

Repeating work (TaskExecutor.StartRepeatingTask): the wait until the next run is now interval - elapsed from a stopwatch instead of comparing two DateTime.Now values, 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 negative durationMillis on backward clock steps and aligns timestamps with how they are serialized.

Diagnostic store / event processor: DiagnosticStoreBase init and periodic events, EventProcessor diagnostic timer delay, and IDiagnosticStore.DataSince / AddStreamInit docs now treat diagnostic times as UTC (fixing off-by-local-offset in creationDate / dataSinceDate).

Tests assert UTC DateTimeKind on stream-init and DataSince, 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.

…tic timestamps

Co-Authored-By: tanderson@launchdarkly.com <tanderson@launchdarkly.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot added the devin-pr PR was created by Devin AI label Sep 1, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

@cursor review

devin-ai-integration Bot and others added 5 commits September 1, 2026 21:06
…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>
@tanderson-ld
tanderson-ld marked this pull request as ready for review September 2, 2026 16:00
@tanderson-ld
tanderson-ld requested a review from a team as a code owner September 2, 2026 16:00
await dataSource.Start();

Assert.False(receivedFailed.ExpectValue());
var streamInit = received.ExpectValue();

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.

Should we update the client and FDv2 with these new asserts?

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.

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>

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.

Should we call out this should also be UTC?

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.

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 jsonbailey 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.

Would calling dateTime.ToUniversalTime() in the CommonSdk fix all callers? The trade-off is that Unspecified kind would then be treated as local.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

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 Unspecified value — and that one is already correct today, so the safety net would introduce a bug rather than prevent one.

After this PR there are only four production callers of FromDateTime: three in DiagnosticStoreBase (all now UTC) and EventProcessorInternal.cs:457, which passes EventSenderResult.TimeFromServer. That comes from DefaultEventSender.cs:117, respDate.Value.DateTime, and DateTimeOffset.DateTime yields Kind = Unspecified with the offset-adjusted clock reading. An HTTP Date header is always GMT, so the offset is zero and the reading is already UTC. Measured on a Pacific host with Date: Tue, 01 Sep 2026 17:24:00 GMT:

offset=00:00:00  DateTime=2026-09-01T17:24:00  kind=Unspecified
as-is           = 1788283440000   (correct)
ToUniversalTime = 1788308640000   (+7h, wrong)

That value feeds _lastKnownPastTime, so debug-mode expiry would start being evaluated against a time seven hours in the future.

Packaging is the other consideration: FromDateTime is public API in LaunchDarkly.CommonSdk, a different package from the one this PR touches, on its own release cadence — so it's a silent behavior change for external callers too, several of whom may be passing Unspecified values that are already epoch-correct.

If you want the defense-in-depth version, I'd order it the other way: make the inputs unambiguous first (respDate.Value.UtcDateTime at the sender, so every FromDateTime argument is Kind = Utc), after which adding ToUniversalTime() in CommonSdk is harmless. Happy to file that as a follow-up — keeping it out of this PR since it spans a second package.

@tanderson-ld
tanderson-ld merged commit e12ee4a into main Sep 2, 2026
18 checks passed
@tanderson-ld
tanderson-ld deleted the devin/1788296418-monotonic-clocks branch September 2, 2026 19:40
tanderson-ld pushed a commit that referenced this pull request Sep 2, 2026
🤖 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>
tanderson-ld added a commit that referenced this pull request Sep 2, 2026
## 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>
tanderson-ld pushed a commit that referenced this pull request Sep 2, 2026
🤖 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>
tanderson-ld pushed a commit that referenced this pull request Sep 3, 2026
🤖 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>
evgenygunko pushed a commit to evgenygunko/Translations that referenced this pull request Sep 4, 2026
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` | ![age](https://developer.mend.io/api/mc/badges/age/nuget/LaunchDarkly.ServerSdk/8.16.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/LaunchDarkly.ServerSdk/8.16.0/8.16.1?slim=true) |

---

### 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 ([#&#8203;343](launchdarkly/dotnet-core#343)) ([fa7bcf9](launchdarkly/dotnet-core@fa7bcf9))
- Retry after partial file reads. ([#&#8203;218](launchdarkly/dotnet-core#218)) ([93f4508](launchdarkly/dotnet-core@93f4508))
- use monotonic clock for intervals and durations, UTC for diagnostic timestamps ([#&#8203;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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

devin-pr PR was created by Devin AI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants