Skip to content

feat(client): Add an Objectstore client for batched uploads - #133

Merged
BYK merged 6 commits into
mainfrom
feat/objectstore-client
Nov 6, 2026
Merged

BYK merged 6 commits into
mainfrom
feat/objectstore-client

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Moves the Objectstore upload client from the Sentry CLI into @sentry/api/client, as requested in review on getsentry/toolkit#2062. That PR fixes sentry snapshots upload hitting HTTP 429. The new CLI sent one request per image, and the Sentry proxy in front of Objectstore rate-limits per request. The old Rust CLI batched requests (Session::many()), and this client does the same.

The logic matches toolkit at 5459297:

  • findExistingObjects sends HEADs in multipart batches to /v1/objects:batch/{usecase}/{scope}/, up to 1000 operations per batch and 4 batches in flight.
  • putObjects batches inserts up to 1 MB each, within 1000 operations and 25 MB per batch, with 4 batches in flight. It PUTs larger objects one by one, up to 5 at once, in parallel with the batches.
  • A failed batch, a failed operation, a non-multipart response, or a response missing an operation throws.

API (@sentry/api/client)

findExistingObjects(config: ObjectstoreConfig, keys: string[], options?: ObjectstoreOptions): Promise<Set<string>>
putObjects(config: ObjectstoreConfig, uploads: ObjectUpload[], options?: ObjectstoreOptions): Promise<void>
type ObjectstoreConfig = { url; usecase; scopes: [string, string][]; authToken?: string | null; expirationPolicy }
type ObjectUpload = { key: string; size: number; read: () => Promise<Uint8Array<ArrayBuffer>> }
type ObjectstoreOptions = { fetch?: (input: string, init?: RequestInit) => Promise<Response> }
class ObjectstoreError extends ApiError { url: string }

Choices

  • Location: the shared client, not a new subpath. @sentry/api/client is already the web-standard request layer that hosts inject things into. Putting the client there means tsconfig.browser.json and portability.test.ts check it automatically.
  • Browser-safe. There are no node: imports and no Buffer. Multipart encoding and parsing work on bytes (TextEncoder, Uint8Array), and boundaries come from crypto.randomUUID(). The host supplies file contents through read(), which runs only when that upload's request starts. The CLI will pass () => readFile(path) and the size from stat.
  • Injectable fetch. It defaults to globalThis.fetch. The CLI will pass its custom-CA fetch.
  • Errors. ObjectstoreError extends this package's ApiError, so instanceof SentryApiError still matches. It adds url, which is the failing object's URL or the request URL, and the CLI's own error type needs it.
  • No new dependencies. A small local limiter replaces p-limit. Once a task fails, queued tasks don't start.
  • putObject and buildObjectUrl stay internal because the CLI only uses the two functions above.

Verification

  • test/api-client/objectstore.test.ts (13 tests, ported from toolkit) covers batch shape, key encoding, concurrency (3 batches in flight for 2,500 keys and for 60 MB), per-operation, request, and protocol errors, and individual PUTs. All 13 pass, and so does the whole test/api-client suite (370 tests).
  • I also ran an E2E test of the built dist against a local ghcr.io/getsentry/objectstore:nightly. 1,200 objects took 2 batched HEAD requests and 3 upload requests (2 batches plus 1 PUT for a 2 MB object). Non-ASCII keys round-tripped, and the bytes read back matched.
  • pnpm build and tsc -p tsconfig.browser.json pass.
  • pnpm typecheck and pnpm test fail on main without this change, and they fail the same way with it. test/typecheck.ts and two smoke.test.ts cases expect a feature_id field that was added to the schema in the last generated spec. Main's Build has been red since a47f626.

Toolkit will switch packages/cli to this client after the next @sentry/api release. Toolkit currently pins 1.26.0, so it needs a catalog bump at the same time.

via @BYK.

--

View Session [Sentry]

Port the Objectstore upload client from the Sentry CLI
(getsentry/toolkit#2062) into `@sentry/api/client`, so hosts share it.
`findExistingObjects` and `putObjects` group HEADs and small inserts
into multipart batch requests (1000 ops, 25 MB, 4 in flight) and PUT
objects over 1 MB individually (5 in flight). One request per object
trips the Sentry proxy's per-request rate limit.

The client uses only web-standard APIs, like the rest of the entry:
hosts pass each upload's bytes through `read()` and may inject `fetch`.
Failures throw `ObjectstoreError`, an `ApiError` carrying the URL.

@BYK BYK left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also add a lint rule to the CLI repo to enforce not adding API-specific stuff there and point them to this repo.

);
}

const delimiter = encoder.encode(`--${boundary}`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OMG, don't we have a native multipart parser in web standards? I'm happy to use an "if node use this, if web use this" thing if they both have separate native versions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked. Neither the web platform nor Node has a native parser that works for this protocol. The only one is Response#formData() (undici in Node, the same API in browsers). It exposes just each part's name, filename and value, and drops any other part headers. Objectstore puts each operation's result in per-part headers: x-sn-batch-operation-index and x-sn-batch-operation-status on responses (see objectstore-server/src/batch.rs). Requests have the same problem: FormData can't set the per-part x-sn-batch-operation-kind/-key/x-sn-expiration headers the server reads. With formData() we'd lose the status and index of every operation, so the hand-rolled byte-level encoder and parser stays. It matches the Rust client's many.rs. I'm leaving this as is unless you'd rather change the server protocol.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you at least file an issue to the relevant repo (guessing this is Relay? Or objectstore (https://github.com/getsentry/objectstore)) to make this multi-part compliant with a good solution proposal. I'm so annoyed right now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed getsentry/objectstore#769. It proposes an opt-in FormData-native batch format: a leading JSON operations manifest in requests, and in responses one JSON "<index>" result part per operation plus a "<index>.body" file part for GET payloads. The current header format stays for the Rust and Python clients. It also asks for a quick standalone fix, because the server's unquoted name=part makes Node's Response.formData() throw on today's responses. Once something like that ships, this client can drop its hand-rolled encoder and parser.

Comment thread lib/api-client/objectstore.ts Resolved

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread lib/api-client/objectstore.ts Resolved
Comment thread lib/api-client/objectstore.ts Resolved
Use p-limit, as the CLI does, instead of a local limiter. The client
entry already has runtime dependencies (http-cache-semantics), and
p-limit uses only web-standard APIs. The client bundle keeps it
external, and the packed-package tests link every runtime dependency.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread lib/api-client/objectstore.ts Resolved
Read the uploads of a batch one at a time, so the batches in flight
hold at most 4 files open instead of up to 1000 each (EMFILE).

Percent-encode each `/`-separated key segment in object URLs with the
path-segment set the Rust client uses (url crate), so keys with `%`,
`?`, `#`, spaces, or non-ASCII PUT the same object that batch
operations address.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bdc75d7. Configure here.

Comment thread lib/api-client/objectstore.ts Resolved
p-limit keeps starting queued tasks after one rejects, so a failed batch
or PUT (e.g. a 429) was followed by more requests and file reads. Clear
the queues of the limiters in the same operation when a task fails,
before p-limit can start the next one.
Match the url crate's SPECIAL_PATH_SEGMENT set, which the Rust client
uses for http(s) object URLs: add `\` (fetch parses it as `/`, storing
the object under another key) and `^`.
@BYK
BYK enabled auto-merge (squash) November 6, 2026 23:23
BYK added a commit that referenced this pull request Nov 6, 2026
`main` has failed Build and Build (Node LTS) since a47f626.

**What broke:** the a47f626 regen of `openapi-derefed.json` added
`feature_id` to the autofix POST response and made it required. That
matches Sentry: `GroupAutofixEndpoint`
(`src/sentry/seer/endpoints/group_ai_autofix.py`) always returns
`{run_id, sentry_run_id, feature_id}`, and the API docs example includes
`"feature_id": "autofix"`. The schema is correct. The test fixtures
still built the old two-field response, which caused:
- `pnpm typecheck`: `test/typecheck.ts(33,7): error TS2741: Property
'feature_id' is missing`
- `pnpm test`: the two "runtime validator entry points" smoke tests
(Valibot and Zod) rejected the fixture.

**Fix:** add `feature_id: "autofix"` to both fixtures. No test or check
is skipped or loosened; the negative `run_id: "42"` cases are unchanged.

**Verification:** I ran the Build workflow steps locally on Node 22.23
from a clean checkout of `main` plus this change. Node LTS wasn't
available locally, so CI covers that job.
- `pnpm install --frozen-lockfile`: pass
- `pnpm build`: pass
- `pnpm typecheck`: pass. Without the change it reports 1 TS error.
- `pnpm test`: 51 files and 635 tests pass. Without the change,
`smoke.test.ts` has 2 failures.
- `npm pack --dry-run`: pass

Unblocks CI on #133.

<!-- junior-request-attribution:start -->
via @BYK.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3A790a7f162e08c84f43da8a5304ce4980 -->

--

[View
Session](https://junior-prod.sentry.dev/conversations/agent%3A790a7f162e08c84f43da8a5304ce4980)
[[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3A790a7f162e08c84f43da8a5304ce4980/?project=4510944073809921)

<!-- junior-session-footer:end -->

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: Burak Yigit Kaya <[email protected]>
@BYK
BYK merged commit 3fd7019 into main Nov 6, 2026
11 checks passed
@BYK
BYK deleted the feat/objectstore-client branch November 6, 2026 23:37
sentry-junior Bot added a commit to getsentry/toolkit that referenced this pull request Nov 7, 2026
Bumps @sentry/api to 1.44.0, which ships the batched Objectstore client
from getsentry/sentry-api-schema#133, and replaces the CLI's own
protocol code with it. src/lib/objectstore.ts is now a thin adapter: it
reads files from disk when their request starts, sends requests through
customFetch so custom CAs still apply, and rethrows ObjectstoreError as
the CLI's ApiError.

Batching and limits are unchanged: 4 concurrent batches, 25 MB per
batch, and 5 concurrent PUTs for files over 1 MB. Protocol tests now
live in @sentry/api; the CLI tests cover the adapter.

The bump also regenerates the alert and event skill references, which
pick up field descriptions from the newer schema.

Co-Authored-By: Jamie Quadri <[email protected]>
Co-Authored-By: Burak Yigit Kaya <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant