Keep telemetry out of S2 when storage is disabled - #413
Merged
Merged
Conversation
BrowserTelemetryConfig gains a storage block whose enabled flag defaults to true when omitted, so existing callers keep today's behavior. PUT and PATCH /telemetry gain a 409 for disabling storage on an instance that has already started it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TelemetryConfig gains StoreS2. PUT reads it with an omitted block meaning true, PATCH leaves an omitted field unchanged, and every response echoes it, including the cleared config, which keeps the toggle the clearing request carried. Nothing acts on it yet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
reconcileStorage, deferred from PUT and PATCH like reconcileExport, opens the S2 sink once a capture session with storage on is committed. It never closes it: the writer binds its stream for the life of the instance, so instead a request that turns storage off after the sink has opened gets a 409 before anything is committed. That guard holds storageMu across the check and the commit so a start already in flight settles first; otherwise a storage-off config could be committed while the sink was opening. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
main no longer starts the controller at boot or from the fork-identity hook; the hook only records the applied payload's stream. While a fork is still waiting for its identity the resolver returns no stream, so a session applied before the handoff leaves the sink closed instead of binding the parent's stream, and the next config after the handoff opens it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The S2 writer read the ring from its oldest event, so a session with storage off followed by one with it on would have written the storage-off events to S2 when the sink opened. The session now records the last seq published before storage was turned on, and the controller opens the writer after it. The first storing session on a fresh process still stores from seq 0, since nothing is published before a session. The controller's Start takes the floor, so the controller tests pass 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PUT and PATCH took storageMu only for storage-off configs. A storage-on update commits provisionally before capture is applied, so a reconcileStorage deferred from an earlier request could read that config and open the sink, and a capture failure then rolled the session back to storage off with the sink open. Every PUT and PATCH now holds storageMu from the storage check until it commits or rolls back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A fork's boot S2_STREAM belongs to the instance it was forked from, so an applied identity without s2_stream now leaves storage closed instead of binding the parent's stream. The platform always sends one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Split the writer's start from opening the S2 session so a test can run it against a mock backend and assert it forwards exactly from afterSeq+1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
archandatta
marked this pull request as ready for review
September 23, 2026 18:00
archandatta
deleted the
archand/kernel-2158/telemetry-storage-toggle
branch
September 23, 2026 19:18
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.
An instance whose telemetry sessions ask for
storage.enabled=falsenever opens its S2 append session, and S2 never receives an event that was captured with storage off.BrowserTelemetryConfiggainsstorage: { enabled }, a sibling ofexport. An omitted block means true, the opposite ofexport, so callers that predate the field keep storing. In a PUT an omitted block means on; in a PATCH an omitted field leaves the current value unchanged.TelemetryState.configechoes it on GET, PUT and PATCH, including on the cleared config a stop request returns, which reflects the toggle that request carried. Callers can compare the echo to what they sent. That matters because the strict handler decodes with plainencoding/json: onlyPOST /replrejects unknown fields, so an image that predates this field acceptsstorageand returns 201 without honoring it.When the sink opens
mainno longer starts the controller at boot or from the fork-identity hook.reconcileStorageis deferred from PUT and PATCH the same way asreconcileExport. It opens the sink once a capture session is active with storage on, and it never closes it. The writer is single-use and binds its stream for the life of the instance, so it still stops only at shutdown. Because it can't be closed, a PUT or PATCH whose resulting config has storage off gets a 409 once the sink has ever opened, and the 409 is returned before anything is committed. A stop PUT carryingstorage.enabled=falseon an instance that has stored is refused the same way, since it asks for a state the instance can no longer honor. A stop PUT that omits storage still clears. A failed start is logged and retried by the next request, matching export.Every PUT and PATCH takes
storageMubefore the storage check and holds it until the config is committed or rolled back.reconcileStorageholds the same lock across its read of the session andStart, so it only ever reads a settled config. That closes two races:reconcileStoragedeferred from an earlier request could read that provisional config and open the sink. If capture then failed, the request would roll back to storage off with the sink open.The lock order is always
monitorMuthenstorageMu, andreconcileStoragenever takesmonitorMu. UnlikeexportMu,storageMuis taken undermonitorMu, so a PUT or PATCH can holdmonitorMuwhile it waits for aStartthat is running.Startdoes not dial, becauses2-sdk-goopens the transport on first submit, so that wait is short.Why the writer now starts after a seq
S2StorageWriterread the ring from its oldest event, which is correct when the sink opens at boot. With the start deferred to the handler, that behavior would copy events from a storage-off session into S2 when a later storage-on config opened the sink. This covers a storage-off session followed by storage-on in the same session, and a cleared storage-off session followed by a storing one.TelemetrySessionnow records the last seq published before storage was turned on for the current session. It reads that seq under the session mutex thatPublishholds, so the boundary is exact.S2StorageController.StartandNewS2StorageWritertake it asafterSeq, and the writer reads the ring after it. The first storing session on a fresh process still stores from seq 0, since nothing reaches the ring without a session:TelemetrySessionis its only publisher and drops everything while inactive. Deferring the start therefore loses nothing. A start that failed and is retried keeps the session's original floor, so events captured with storage on but not yet stored are still sent.This changes the controller's
Startsignature, andS2StorageWriter.Startis split so the forwarding half can run against a mock backend. The controller tests from #401 now pass0. New tests assert that the writer forwards exactly fromafterSeq+1, and that a controller started after the ring head reads nothing.Forks
The fork hook now only records the applied payload's
S2_STREAMin-process, and the handoff no longer starts anything. While the fork-identity wait is armed and no identity has been applied, the resolver returns no stream. A storing config applied before the handoff therefore leaves the sink closed and logs a warning, where the old code would have bound the parent's stream. The next PUT or PATCH after the handoff opens the sink on the fork's stream. If no config follows the handoff, the sink stays closed until one does. A fork's bootS2_STREAMbelongs to the parent, so an applied payload withouts2_streamnow leaves storage closed. #404 fell back to the boot value in that case. The platform always sendss2_streamas the fork's own instance name, so the fallback could only ever bind the parent's stream.Unchanged: shutdown order and the bounded
Stop, missing credentials or stream keeping the sink closed, export semantics, and the resolver's restart path through the persisted applied payload.Testing
go build ./...,go vet ./...— cleango test $(go list ./... | grep -v /e2e$) -count=1 -race— every package passes at head. An earlier run hitTestUpstreamManagerDetectsChromiumAndRestartinlib/devtoolsproxy, which this PR does not touch, on the samet.TempDirteardown race Drive the S2 storage sink from its controller #404 reported. It passed-count=3on rerun.seqandapplied_atunchanged, echoes on every response, the start floor after storage-off capture, the retry floor, and the writer's exact first seq. They also cover a storage-off request waiting on an in-flight start, and a deferred reconcile waiting out a storage-on PUT or PATCH that rolls back. Finally, they cover no ring publish without a session, and the resolver withholding the parent stream while a fork is pending or its payload has no stream. Each guarded line was mutated and its test failed: the lock scope, an early lock release, each 409, the floor off by one in both directions, and both fork fallbacks. One check turned out redundant and was removed./var/log/supervisord/kernel-images-api:S2 storage enabledat boot;PUTnetwork 201 and still 0;GETechoesstorage.enabled=truePUTwith storage omitted 201 and 1;POST /telemetry/events200 and the event arrives on/telemetry/stream; storage-offPUT,PATCH, and clearingPUTall 409;GETafterward stilltruePUT201 and 0; two events published (seqs 1–2) reach the stream with still 0 lines;PUTstorage omitted 200 and 1; the next event is seq 3, and S2 received only seq 3, as shown by the SDK's per-record ack errors against the fake basinS2_STREAM=seed-s: 0 at boot;POST /internal/fork-identity204 and still 0;PUT201 and 1, bound tostream=fork-sPUTbefore the handoff: 201, 0 lines, and one pending warning; after the handoff aPATCHopens it onstream=fork-ss2_stream: handoff 204, thenPUT201 with 0 linesdocker stop -t 30in both fake-S2 modes: exit 0, no drain warning. Ones2 storage writer stop failedline appears because the fake basin's host does not resolve. The main image logs the same line under the same steps, and this image logs none when no event was sent.go test ./e2e/ -count=1 -timeout 110magainst headless and headful images built from this branch: 52 passed, 0 failed, 2 skipped (34 subtests pass), the same as Drive the S2 storage sink from its controller #404 reported for main. The skips areTestReplayRecordingZombocomArchiveAudio, which needs a network fixture, andTestS2StorageWriter, which needs real S2 credentials.TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1was set because this machine could not resolvelocalhost.Not run: real S2 credentials, which were not available. Records reaching a real stream were checked only through the fake basin's per-record ack errors.
🤖 Generated with Claude Code
Note
Medium Risk
Changes when and what telemetry is persisted to S2 and fork stream binding, which affects data isolation on forks and irreversible storage-on instances; extensive tests mitigate regressions.
Overview
Adds a
storage.enabledtoggle to telemetry (default on when omitted, unlike export) and defers opening the S2 append sink until the first capture session that wants storage, viareconcileStorageon PUT/PATCH instead of at API boot or on fork handoff.Once the sink has opened it cannot be turned off: disabling storage returns 409 with the config unchanged.
storageMukeeps the disable check and deferred reconcile aligned with committed (or rolled-back) config so an in-flight start or a failed capture apply cannot leave storage off with the sink open.The writer now starts with an
afterSeqfloor from the session so ring events captured while storage was off are not persisted when storage is enabled later. Fork stream resolution no longer binds the parent’s bootS2_STREAMwhile identity is pending or when the applied payload has no stream.Reviewed by Cursor Bugbot for commit 829b0f7. Bugbot is set up for automated code reviews on this repo. Configure here.