Fix span timestamps staying stale after an AWS Lambda SnapStart restore#11882
Open
sheldon-nadarajah-sft wants to merge 4 commits into
Open
Fix span timestamps staying stale after an AWS Lambda SnapStart restore#11882sheldon-nadarajah-sft wants to merge 4 commits into
sheldon-nadarajah-sft wants to merge 4 commits into
Conversation
CoreTracer caches a startTimeNano/startNanoTicks reference pair once at construction and derives every span timestamp from a monotonic-tick delta off it, self-correcting drift once clockSyncPeriod (default 30s) of *monotonic* time has elapsed since the last check. AWS Lambda SnapStart checkpoints the JVM at snapshot-creation time and restores a new execution environment from that snapshot later - potentially hours or days later, on different hardware. System.nanoTime() does not account for that gap, so a short-lived restore-then-invoke sequence never accumulates enough monotonic ticks to trigger the periodic correction, and every span stays anchored near the original snapshot-creation instant instead of the real invocation time. Confirmed in production: DynamoDB child spans timestamped ~2 hours before their own parent Lambda span, landing squarely inside the snapshot's original platform.initStart window. Resyncs the cached clock reference from CoreTracer.notifyLambdaStart(), already invoked once per Lambda invocation before any span for that invocation is created. Any real restore is always followed by an invocation, so this catches it without reacting to the restore event itself or needing a new runtime dependency. Gated behind a new, opt-in, disabled-by-default config: DD_TRACE_LAMBDA_SNAPSTART_CLOCK_RESYNC_ENABLED. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
mcculls
reviewed
Jul 8, 2026
| * <p>Not final: reset by {@link #maybeResyncClockForLambdaInvocation()} on every Lambda invocation - | ||
| * see {@link #startTimeNano}. | ||
| */ | ||
| private volatile long startNanoTicks; |
Contributor
There was a problem hiding this comment.
Couldn't you forcibly trigger an update of counterDrift from maybeResyncClockForLambdaInvocation?
Or alternatively update counterDrift (and lastSyncTicks) directly from that method.
That would let you account for the drift without making these final fields volatile.
Author
There was a problem hiding this comment.
Good call — done in 50f48bd. startTimeNano/startNanoTicks stay final; maybeResyncClockForLambdaInvocation() now computes the drift against those fixed values and folds it into counterDrift, updating lastSyncTicks too, so no new volatile fields.
mcculls pointed out on DataDog#11882 that resetting startTimeNano/startNanoTicks from maybeResyncClockForLambdaInvocation() only works if those fields are volatile, since they're read on every getTimeWithNanoTicks() call from arbitrary tracing threads. Instead, fold the observed drift directly into counterDrift (already volatile, already used by the periodic self-correction in getTimeWithNanoTicks) and update lastSyncTicks, leaving startTimeNano/ startNanoTicks final as before. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Datadog flamegraphs for AWS Lambda functions with SnapStart enabled can render as a multi-hour (or multi-day) empty timeline with the actual work squeezed into a tiny sliver at each end — even though the real invocation took under a second.
We tracked this down from a production trace on a Java Lambda function with SnapStart enabled: the parent
aws.lambdaspan was timestamped correctly (13:42:21Z), but its 18 DynamoDB child spans — created by this tracer's AWS SDK instrumentation during the same invocation — were all timestamped11:45:25Z, ~1h57m earlier. Correlating against CloudWatch logs for that invocation confirmed the DynamoDB spans' timestamp landed squarely inside the original snapshot-creationplatform.initStartwindow (when the SnapStart snapshot was published), not anywhere near the actualplatform.restoreStart/invocation time that ran ~2 hours later.Root cause
CoreTracercomputes every span's timestamp from a cached reference pair —startTimeNano/startNanoTicks— captured once at construction, plus a monotonic-tick delta (getTimeWithNanoTicks()). It self-corrects drift periodically, but that correction is gated on monotonic ticks elapsed (nanoTicks - lastSyncTicks >= clockSyncPeriod, default 30s), not wall-clock time elapsed.AWS Lambda SnapStart checkpoints the JVM at snapshot-creation time and later restores a new execution environment from that snapshot — potentially hours or days later, on different hardware.
System.nanoTime()does not account for that gap: the restored instance's monotonic clock has no relationship to real elapsed time since the snapshot. A short-lived restore-then-invoke sequence (our production case: restore → invocation within ~14s) never accumulates 30 seconds of monotonic ticks, so the periodic correction never fires, and every span stays anchored near the original snapshot-creation instant.This is a real, reproducible bug — not a config mistake on the user's side — and it's specific to SnapStart's restore-into-a-new-context semantics: a regular (non-SnapStart) Lambda container merely pauses/resumes the same continuously-existing process between invocations, which does not exhibit this clock discontinuity.
Fix
Resync the cached clock reference from
CoreTracer.notifyLambdaStart()— already invoked once per Lambda invocation, before any span for that invocation is created (seeLambdaHandlerInstrumentation's@OnMethodEnteradvice, which calls it immediately before the firststartSpan(...)call). Any real SnapStart restore is always followed by an invocation, so resyncing there catches it without needing to react to the restore event itself.This is gated behind a new, opt-in, disabled-by-default config,
DD_TRACE_LAMBDA_SNAPSTART_CLOCK_RESYNC_ENABLED. Doing this on every invocation (not just ones following a restore) is deliberate: outside SnapStart there's no clock bug to fix (see above), so it's a correct, cheap no-op there — a few volatile field writes, negligible next to the HTTP round-tripnotifyLambdaStartalready makes to the Lambda extension.Alternatives considered
DD_TRACE_CLOCK_SYNC_PERIODglobally. The periodic correction is clamped to a 1ms floor regardless of configured value, so setting it to0does mitigate most of this today, with zero code changes, and users hitting this can do so immediately. We didn't pursue this as the fix because it's a blanket per-tracer-instance setting affecting every span for every user, not just Lambda SnapStart ones, and it only shrinks the bad window rather than eliminating it.org.crac)afterRestorehook, reacting to the actual restore event via the JVM checkpoint/restore SPI. We built and tested this first, but self-review turned up real problems specific to that approach: it requires a new runtime dependency that, in our build, ended up shipping a second unrelocated copy oforg.cracin the shaded agent jar (a class that, it turns out, is already present unrelocated via an existing transitive dependency — likely Spring, given Spring Framework 6.1+ ships its own CRaC integration and is a common SnapStart audience); it publishedthisfrom mid-constructor into a JVM-wide static registry; and the registered callback fired on an arbitrary async thread with no test coverage of the actual registration path. ThenotifyLambdaStarthook needs no new dependency, fires deterministically on a known thread at a well-defined point in the invocation lifecycle, and is already exercised by existing Lambda instrumentation tests.Known limitation
The resync writes
startTimeNano/startNanoTicks/lastSyncTicks/counterDriftas four independent volatile fields with no encompassing lock, so a concurrent reader (e.g. the background trace-buffer flush thread) could in principle observe a partially-updated combination. This isn't a new class of risk introduced here:getTimeWithNanoTicks()'s existing periodic self-correction already updatescounterDriftandlastSyncTicksindependently today, with the same eventual-consistency trade-off (a worst-case redundant correction on the next call). We didn't expand scope to refactor that pre-existing pattern into an atomic holder for this fix, but flagging it here for visibility.Testing
CoreTracerTest: a test characterizing the underlying bug in isolation (getTimeWithNanoTickswith a stalenanoTicksreading stays anchored to construction time), plus tests exercising the real fix end-to-end throughnotifyLambdaStart()— resync happens when explicitly enabled, and (the default) does not happen when left disabled.