mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The server keeps attachments and other files in a storage provider. Hosted instances use the S3 provider. > - Sentry received `SignatureDoesNotMatch` errors from the S3 provider on two staging hosts. > - The events have only AWS SDK stack frames. They do not show which S3 call failed, the HTTP status, or the S3 request ID. > - Without these facts, we cannot find the cause or make a safe fix. > - This pull request records a small, fixed-shape failure record on the S3 error and sends it with the Sentry event. > - The benefit is that the next event shows the failed operation and the provider request ID, and no private data goes to Sentry. ## Linked Issues or Issue Description **What happened?** The S3 storage provider threw `SignatureDoesNotMatch` four times on staging. The Sentry events do not identify the S3 operation or the caller. **Expected behavior** A storage provider failure in Sentry shows which storage call failed and the provider's correlation fields. **Steps to reproduce** 1. Configure the S3 storage provider against an endpoint that rejects the request signature. 2. Read, write, check, or delete a stored file. 3. Look at the Sentry event. It has no first-party stack frame and no operation name. No public GitHub issue exists. I searched open and closed pull requests for `SignatureDoesNotMatch` and storage failure diagnostics. No pull request covers this. ## What Changed - Add `server/src/storage/failure-diagnostics.ts`. It keeps a frozen, fixed-shape record for each failed storage error in a `WeakMap`. - Add `server/src/storage/s3-failure.ts`. It wraps each S3 call and records the operation, error code, HTTP status, request ID, and SDK attempt count. It reads only data properties. A value that does not match its bounded format becomes `unknown` or `null`. - Wrap `PutObject`, `GetObject`, `HeadObject`, `DeleteObject`, `CreateMultipartUpload`, `UploadPart`, and `CompleteMultipartUpload`. - In `captureException`, add the `storage_operation` and `storage_error_code` tags and a `storage_failure` context to the event of that error only. The original error and its default grouping do not change. - Document the new context in `doc/observability.md`. ## Verification - `pnpm exec vitest run src/__tests__/storage-s3-failure-diagnostics.test.ts src/__tests__/sentry.test.ts` (in `server/`): 37 passed, 5 skipped. The skipped tests are the existing real-SDK tests, which need an environment flag. - `pnpm exec vitest run src/__tests__/storage-s3-multipart.test.ts src/__tests__/storage-local-provider.test.ts`: 8 passed. - The new tests use a local HTTP server that returns a real S3 `SignatureDoesNotMatch` XML body with a `StringToSign` field. The tests make sure that the record has the operation, code, status and request ID, and that it does not contain the private string. - I typechecked the changed source files with a scoped `tsc` project. They have no errors. I could not run the full `pnpm -r typecheck` and `pnpm build` in my sandbox: it has a 4 GiB memory limit and no `cargo` for the runner binary. CI runs the full checks. ## Risks - Low risk. The change adds a `try`/`catch` that records data and throws the same error again. Storage behavior, not-found handling, and HTTP responses do not change. - Secret-sensitive routes replace the error before reporting, so those events do not get the record. This is intended. - The record never contains the bucket, object key, endpoint, credentials, error message, or provider response body. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic. Used through Claude Code with tool use (shell, file edit) and extended reasoning. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
54 lines
3.1 KiB
TypeScript
54 lines
3.1 KiB
TypeScript
import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, UploadPartCommand, type S3Client } from "@aws-sdk/client-s3";
|
|
import { Readable } from "node:stream";
|
|
import type { PutObjectInput } from "./types.js";
|
|
import { withS3FailureDiagnostics } from "./s3-failure.js";
|
|
|
|
/** Each part is streamed too: multipart must not become another whole-file
|
|
* buffer or a fixed part-count ceiling below the storage provider's limits. */
|
|
export async function putS3Multipart(client: S3Client, bucket: string, key: string, input: PutObjectInput) {
|
|
const source = input.body instanceof Readable ? input.body : Readable.from([input.body]);
|
|
const iterator = source[Symbol.asyncIterator]();
|
|
let pending = Buffer.alloc(0);
|
|
let uploadId: string | undefined;
|
|
// Scale part sizes to the S3 part-count limit while retaining stream backpressure.
|
|
const partSize = Math.max(8 * 1024 * 1024, Math.ceil(input.contentLength / 10_000));
|
|
try {
|
|
uploadId = (await withS3FailureDiagnostics("create_multipart_upload", () => client.send(new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: input.contentType })))).UploadId;
|
|
if (!uploadId) throw new Error("S3 did not return a multipart upload id");
|
|
const parts: { ETag: string; PartNumber: number }[] = [];
|
|
for (let offset = 0; offset < input.contentLength;) {
|
|
const length = Math.min(partSize, input.contentLength - offset);
|
|
let remaining = length;
|
|
const body = Readable.from((async function* () {
|
|
while (remaining > 0) {
|
|
if (!pending.length) {
|
|
const next = await iterator.next();
|
|
if (next.done) throw new Error("S3 upload source was truncated");
|
|
pending = Buffer.isBuffer(next.value) ? next.value : Buffer.from(next.value);
|
|
if (!pending.length) continue;
|
|
}
|
|
const count = Math.min(remaining, pending.length);
|
|
const chunk = pending.subarray(0, count);
|
|
pending = pending.subarray(count);
|
|
remaining -= count;
|
|
yield chunk;
|
|
}
|
|
})());
|
|
try {
|
|
const result = await withS3FailureDiagnostics("upload_part", () => client.send(new UploadPartCommand({ Bucket: bucket, Key: key, UploadId: uploadId, PartNumber: parts.length + 1, Body: body, ContentLength: length })));
|
|
if (remaining || !result.ETag) throw new Error("S3 upload part is incomplete");
|
|
parts.push({ ETag: result.ETag, PartNumber: parts.length + 1 });
|
|
} finally { body.destroy(); }
|
|
offset += length;
|
|
}
|
|
if (pending.length || !(await iterator.next()).done) throw new Error("S3 upload source exceeds its declared size");
|
|
await withS3FailureDiagnostics("complete_multipart_upload", () => client.send(new CompleteMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts } })));
|
|
} catch (error) {
|
|
if (uploadId) await client.send(new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId })).catch(() => {});
|
|
throw error;
|
|
} finally {
|
|
source.destroy();
|
|
await iterator.return?.();
|
|
}
|
|
}
|