Skip to content

Commit 608076d

Browse files
authored
Adjust s3 write path for claude code cloud telemetry (#293)
<!-- CURSOR_SUMMARY --> > [!NOTE] > **Medium Risk** > Breaking change for anything listing or ingesting the old object key scheme; upload wire format is now gzip with updated headers and S3 canonical request signing. > > **Overview** > Cloud agent runtime uploads now use a **new object layout** and **gzip payloads** on both GCS and S3. Keys move from hive-style paths like `provider=…/user_id=…/run_id=…/runtime.jsonl` to `<prefix>/runtime/date=YYYY-MM-DD/<unix>-<provider>-<run_id>.jsonl.gz`, with `user_id` and `repository` no longer in the key. > > Before upload, snapshots are compressed; requests set `Content-Type` to `application/x-ndjson`, `Content-Encoding: gzip`, and S3 SigV4 signing includes `content-encoding`. Shuttle state stores `provider` and `run_id` and **`uploadObjectName` reuses `LastObject`** for the same run so later uploads in a session target the same key instead of minting a new one each time. > > Tests cover the new naming, gzip bodies, and key reuse; README and Claude/Cursor cloud docs describe the new paths and `gzip -dc` inspection. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 0453cf5. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
2 parents f597c69 + 0453cf5 commit 608076d

5 files changed

Lines changed: 212 additions & 41 deletions

File tree

cli/beacon-hooks/internal/cloudshuttle/shuttle.go

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cloudshuttle
22

33
import (
44
"bytes"
5+
"compress/gzip"
56
"context"
67
"crypto"
78
"crypto/hmac"
@@ -33,12 +34,14 @@ const (
3334
defaultTokenURI = "https://oauth2.googleapis.com/token"
3435
defaultGCSEndpoint = "https://storage.googleapis.com"
3536
defaultS3Region = "us-east-1"
36-
contentTypeJSONL = "text/plain; charset=utf-8"
37+
contentTypeJSONL = "application/x-ndjson"
38+
contentEncoding = "gzip"
3739
uploadGCS = "gcs"
3840
uploadS3 = "s3"
3941
)
4042

4143
var httpClient = http.DefaultClient
44+
var nowUTC = func() time.Time { return time.Now().UTC() }
4245

4346
type Config struct {
4447
Upload string
@@ -69,6 +72,8 @@ type state struct {
6972
LastUpload string `json:"last_upload,omitempty"`
7073
LastSize int64 `json:"last_size,omitempty"`
7174
LastObject string `json:"last_object,omitempty"`
75+
Provider string `json:"provider,omitempty"`
76+
RunID string `json:"run_id,omitempty"`
7277
}
7378

7479
type tokenResponse struct {
@@ -155,28 +160,50 @@ func Upload(ctx context.Context, cfg Config, force bool) error {
155160
return err
156161
}
157162
defer cleanup()
158-
objectName := ObjectName(cfg)
163+
objectName := uploadObjectName(cfg)
159164
if err := uploadSnapshot(ctx, cfg, objectName, snapshot); err != nil {
160165
return err
161166
}
162167
return writeState(cfg.StatePath, state{
163168
LastUpload: time.Now().UTC().Format(time.RFC3339),
164169
LastSize: info.Size(),
165170
LastObject: objectName,
171+
Provider: cfg.Provider,
172+
RunID: cfg.RunID,
166173
})
167174
}
168175

169-
func ObjectName(cfg Config) string {
170-
parts := cleanKeyParts(cfg.Prefix)
171-
parts = append(parts, "provider="+cleanKeyPart(defaultString(cfg.Provider, "unknown")))
172-
if cfg.UserID != "" {
173-
parts = append(parts, "user_id="+cleanKeyPart(cfg.UserID))
176+
func uploadObjectName(cfg Config) string {
177+
st, err := readState(cfg.StatePath)
178+
if err == nil && stateObjectMatchesConfig(st, cfg) {
179+
return strings.TrimSpace(st.LastObject)
180+
}
181+
return ObjectName(cfg)
182+
}
183+
184+
func stateObjectMatchesConfig(st state, cfg Config) bool {
185+
objectName := strings.TrimSpace(st.LastObject)
186+
if objectName == "" {
187+
return false
174188
}
175-
if cfg.Repository != "" {
176-
parts = append(parts, cleanKeyParts("repo="+cfg.Repository)...)
189+
if st.Provider != "" || st.RunID != "" {
190+
return st.Provider == cfg.Provider && st.RunID == cfg.RunID
177191
}
178-
parts = append(parts, "run_id="+cleanKeyPart(defaultString(cfg.RunID, "unknown")))
179-
parts = append(parts, "runtime.jsonl")
192+
provider := cleanKeyPart(defaultString(cfg.Provider, "unknown"))
193+
runID := cleanKeyPart(defaultString(cfg.RunID, "unknown"))
194+
return strings.HasSuffix(path.Base(objectName), "-"+provider+"-"+runID+".jsonl.gz")
195+
}
196+
197+
func ObjectName(cfg Config) string {
198+
now := nowUTC()
199+
parts := cleanKeyParts(cfg.Prefix)
200+
parts = append(parts, "runtime", "date="+now.Format("2006-01-02"))
201+
filename := strings.Join([]string{
202+
fmt.Sprintf("%d", now.Unix()),
203+
cleanKeyPart(defaultString(cfg.Provider, "unknown")),
204+
cleanKeyPart(defaultString(cfg.RunID, "unknown")),
205+
}, "-") + ".jsonl.gz"
206+
parts = append(parts, filename)
180207
return path.Join(parts...)
181208
}
182209

@@ -325,16 +352,52 @@ func parseRSAPrivateKey(raw string) (*rsa.PrivateKey, error) {
325352
}
326353

327354
func uploadSnapshot(ctx context.Context, cfg Config, objectName, filePath string) error {
355+
compressedPath, cleanup, err := gzipSnapshot(filePath)
356+
if err != nil {
357+
return err
358+
}
359+
defer cleanup()
328360
switch cfg.Upload {
329361
case uploadS3:
330-
return uploadS3Object(ctx, cfg, objectName, filePath)
362+
return uploadS3Object(ctx, cfg, objectName, compressedPath)
331363
default:
332364
token, err := accessToken(ctx, cfg.CredentialsB64)
333365
if err != nil {
334366
return err
335367
}
336-
return uploadGCSObject(ctx, cfg.GCSEndpoint, cfg.Bucket, objectName, filePath, token)
368+
return uploadGCSObject(ctx, cfg.GCSEndpoint, cfg.Bucket, objectName, compressedPath, token)
369+
}
370+
}
371+
372+
func gzipSnapshot(filePath string) (string, func(), error) {
373+
source, err := os.Open(filePath)
374+
if err != nil {
375+
return "", func() {}, err
337376
}
377+
defer source.Close()
378+
temp, err := os.CreateTemp(filepath.Dir(filePath), "beacon-cloud-*.jsonl.gz")
379+
if err != nil {
380+
return "", func() {}, err
381+
}
382+
tempPath := temp.Name()
383+
cleanup := func() { _ = os.Remove(tempPath) }
384+
gz := gzip.NewWriter(temp)
385+
if _, err := io.Copy(gz, source); err != nil {
386+
_ = gz.Close()
387+
_ = temp.Close()
388+
cleanup()
389+
return "", func() {}, err
390+
}
391+
if err := gz.Close(); err != nil {
392+
_ = temp.Close()
393+
cleanup()
394+
return "", func() {}, err
395+
}
396+
if err := temp.Close(); err != nil {
397+
cleanup()
398+
return "", func() {}, err
399+
}
400+
return tempPath, cleanup, nil
338401
}
339402

340403
func uploadGCSObject(ctx context.Context, endpoint, bucket, objectName, filePath, token string) error {
@@ -353,6 +416,7 @@ func uploadGCSObject(ctx context.Context, endpoint, bucket, objectName, filePath
353416
}
354417
req.Header.Set("Authorization", "Bearer "+token)
355418
req.Header.Set("Content-Type", contentTypeJSONL)
419+
req.Header.Set("Content-Encoding", contentEncoding)
356420
resp, err := httpClient.Do(req)
357421
if err != nil {
358422
return err
@@ -391,6 +455,7 @@ func uploadS3Object(ctx context.Context, cfg Config, objectName, filePath string
391455
req.ContentLength = info.Size()
392456
}
393457
req.Header.Set("Content-Type", contentTypeJSONL)
458+
req.Header.Set("Content-Encoding", contentEncoding)
394459
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
395460
req.Header.Set("X-Amz-Date", time.Now().UTC().Format("20060102T150405Z"))
396461
if cfg.S3SessionToken != "" {
@@ -414,6 +479,7 @@ func signS3Request(req *http.Request, cfg Config, payloadHash string) {
414479
dateStamp := amzDate[:8]
415480
credentialScope := dateStamp + "/" + cfg.S3Region + "/s3/aws4_request"
416481
headers := map[string]string{
482+
"content-encoding": req.Header.Get("Content-Encoding"),
417483
"content-type": req.Header.Get("Content-Type"),
418484
"host": req.URL.Host,
419485
"x-amz-content-sha256": payloadHash,

0 commit comments

Comments
 (0)