Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions docs/src/fragments/commands/debug-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,14 @@ sentry debug-files upload ./build --no-upload
files via the chunk-upload protocol. Use `--type`/`--id` to restrict which
files are sent, `--no-debug`/`--no-unwind`/`--no-sources` to drop files whose
only useful feature is the named one, and `--include-sources` to attach a
source bundle per file. `--no-upload` previews the selection without
credentials; `--wait`/`--wait-for` block on server-side processing and exit
non-zero if any file fails. `--require-all` fails if a requested `--id` was not
found. Scanning inside ZIP archives, `--symbol-maps`, `--il2cpp-mapping` line
mappings, and `--derived-data` are not yet supported.
source bundle per file. `--derived-data` additionally scans Xcode's
`~/Library/Developer/Xcode/DerivedData` folder (macOS only). `--no-upload`
previews the selection without credentials; `--wait`/`--wait-for` block on
server-side processing and exit non-zero if any file fails. `--require-all`
fails if a requested `--id` was not found. The server-advertised maximum file
size and maximum processing wait are honored automatically (oversized files
are skipped with a warning). Scanning inside ZIP archives, `--symbol-maps`,
and `--il2cpp-mapping` line mappings are not yet supported.
- Upload a JVM bundle separately via `sentry debug-files upload --type jvm`.
- Supported JVM source file extensions: `.java`, `.kt`, `.scala`, `.sc`,
`.groovy`, `.gvy`, `.gy`, `.gsh`, `.clj`, `.cljc`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Upload debug information files to Sentry
- `--no-unwind - Do not upload files whose only feature is unwind info`
- `--no-sources - Do not upload files whose only feature is source info`
- `--include-sources - Build and upload a source bundle for each file with debug info`
- `--derived-data - Also scan Xcode's DerivedData folder (macOS only)`
- `--no-upload - Scan and print what would be uploaded without uploading`
- `--wait - Wait for server-side processing and report any errors`
- `--wait-for <value> - Wait up to this many seconds for server-side processing`
Expand Down
204 changes: 153 additions & 51 deletions src/commands/debug-files/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,23 @@
* Org/project are resolved via the standard cascade (DSN auto-detection, env
* vars, config defaults), so `--no-upload` (dry-run) needs no credentials.
*
* This is the first stage of `debug-files upload` parity. ZIP scanning,
* `--symbol-maps`, `--il2cpp-mapping` line mappings, and `--derived-data` are
* deferred to follow-up PRs (see the command's full description).
* Honors the server-advertised `max_file_size` (oversized files are skipped)
* and `max_wait` (clamps the processing wait). `--derived-data` additionally
* scans Xcode's DerivedData folder on macOS. ZIP scanning, `--symbol-maps`,
* and `--il2cpp-mapping` line mappings are deferred to follow-up PRs (see the
* command's full description).
*/

import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { basename } from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join } from "node:path";
import type { SentryContext } from "../../context.js";
import {
type ChunkServerOptions,
DEFAULT_MAX_DIF_SIZE,
getChunkUploadOptions,
} from "../../lib/api/chunk-upload.js";
import {
DEBUG_FILES_MAX_WAIT_MS,
type DebugFileUpload,
Expand Down Expand Up @@ -46,6 +54,40 @@

const USAGE_HINT = "sentry debug-files upload <path>...";

/** Relative path to Xcode's DerivedData folder under the user's home dir. */
const DERIVED_DATA_SUBPATH = "Library/Developer/Xcode/DerivedData";

/**
* Resolve the effective scan paths, optionally appending Xcode's DerivedData
* folder when `--derived-data` is set.
*
* DerivedData only exists on macOS; on other platforms the flag is a no-op
* (with a warning). The folder is appended only when it actually exists, so the
* stricter `scanPaths` existence check (which throws on a missing explicit
* path) is never tripped by an absent DerivedData directory.
*
* @param paths - Positional paths supplied on the command line.
* @param derivedData - Whether `--derived-data` was passed.
* @returns The effective list of paths to scan.
*/
function collectScanPaths(paths: string[], derivedData: boolean): string[] {
if (!derivedData) {
return paths;
}
if (process.platform !== "darwin") {
log.warn("--derived-data is only supported on macOS; ignoring it.");
return paths;
}
const derivedDataPath = join(homedir(), DERIVED_DATA_SUBPATH);
if (!existsSync(derivedDataPath)) {
log.warn(
`Xcode DerivedData folder not found at ${derivedDataPath}; ignoring --derived-data.`
);
return paths;
}
return [...paths, derivedDataPath];
}

// ── Types ───────────────────────────────────────────────────────────

/** Per-file entry in the command result. */
Expand Down Expand Up @@ -83,6 +125,7 @@
"no-unwind"?: boolean;
"no-sources"?: boolean;
"include-sources"?: boolean;
"derived-data"?: boolean;
"no-upload"?: boolean;
wait?: boolean;
"wait-for"?: number;
Expand Down Expand Up @@ -253,10 +296,14 @@
*/
function* doDryRun(
setExitCode: (code: number) => void,
difs: DebugFileUpload[],
missingIds: string[],
requireAll: boolean
params: {
difs: DebugFileUpload[];
missingIds: string[];
requireAll: boolean;
oversizedCount: number;
}
) {
const { difs, missingIds, requireAll, oversizedCount } = params;
yield new CommandOutput<DebugFilesUploadResult>({
uploaded: false,
files: difs.map((d) => ({ name: d.name, debugId: d.debugId })),
Expand All @@ -266,11 +313,20 @@
setExitCode(1);
return { hint: `Missing requested debug id(s): ${missingIds.join(", ")}` };
}
if (difs.length > 0) {
return {
hint: `Would upload ${difs.length} debug file(s). Remove --no-upload to upload.`,
};
}
// Distinguish "nothing matched" from "files were skipped for size" so a
// dry-run does not misleadingly report an empty scan. The count reflects
// size-skipped files of a requested type; it does not claim that was the
// only reason nothing would upload.
return {
hint:
difs.length === 0
? `No debug information files found. Try: ${USAGE_HINT}`
: `Would upload ${difs.length} debug file(s). Remove --no-upload to upload.`,
oversizedCount > 0
? `${oversizedCount} file(s) would be skipped for exceeding the maximum file size.`
: `No debug information files found. Try: ${USAGE_HINT}`,
};
}

Expand All @@ -280,19 +336,35 @@
*/
function* doNothingToUpload(
setExitCode: (code: number) => void,
missingIds: string[],
requireAll: boolean
params: {
missingIds: string[];
requireAll: boolean;
oversizedCount: number;
maxFileSize: number;
}
) {
log.warn("No debug information files found.");
const { missingIds, requireAll, oversizedCount, maxFileSize } = params;
yield new CommandOutput<DebugFilesUploadResult>({
uploaded: false,
files: [],
filesUploaded: 0,
});
// --require-all takes precedence: a requested id that wasn't found is the
// most actionable failure, regardless of why the queue ended up empty.
if (missingIds.length > 0 && requireAll) {
setExitCode(1);
return { hint: `Missing requested debug id(s): ${missingIds.join(", ")}` };
}
// Files of a requested type were found but skipped for size. Fail non-zero
// with an accurate count (this does not claim it was the *only* reason the
// queue is empty — other candidates may have failed id/feature filters).
if (oversizedCount > 0) {
setExitCode(1);
return {
hint: `No debug files were uploaded: ${oversizedCount} file(s) exceeded the maximum file size (${maxFileSize} bytes).`,
};
}
log.warn("No debug information files found.");
return { hint: `No debug information files found. Try: ${USAGE_HINT}` };
}

Expand All @@ -311,6 +383,7 @@
maxWaitMs: number;
missingRequestedIds: string[];
requireAll: boolean;
serverOptions?: ChunkServerOptions;
}
) {
const results = await uploadDebugFiles(params);
Expand Down Expand Up @@ -374,15 +447,17 @@
" sourcebundle, jvm\n" +
" --id Only upload the object with the given debug id (repeatable)\n" +
" --no-debug / --no-unwind / --no-sources Drop files whose only\n" +
" useful feature is the named one\n\n" +
" useful feature is the named one\n" +
" --derived-data Also scan Xcode's DerivedData folder (macOS only)\n\n" +
"Usage:\n" +
" sentry debug-files upload ./build\n" +
" sentry debug-files upload ./libexample.so --include-sources\n" +
" sentry debug-files upload ./dsyms --type dsym --wait\n" +
" sentry debug-files upload --derived-data --no-upload\n" +
" sentry debug-files upload ./build --no-upload\n\n" +
"Not yet supported (planned): scanning inside ZIP archives, " +
"--symbol-maps (BCSymbolMap resolution), --il2cpp-mapping line " +
"mappings, and --derived-data.",
"--symbol-maps (BCSymbolMap resolution), and --il2cpp-mapping line " +
"mappings.",
},
output: {
human: formatUploadResult,
Expand Down Expand Up @@ -443,6 +518,12 @@
optional: true,
default: false,
},
"derived-data": {
kind: "boolean",
brief: "Also scan Xcode's DerivedData folder (macOS only)",
optional: true,
default: false,
},
"no-upload": {
kind: "boolean",
brief: "Scan and print what would be uploaded without uploading",
Expand All @@ -467,10 +548,15 @@
},
},
async *func(this: SentryContext, flags: UploadFlags, ...paths: string[]) {
if (paths.length === 0) {
const scanTargets = collectScanPaths(paths, Boolean(flags["derived-data"]));
if (scanTargets.length === 0) {
throw new ContextError("Debug file path(s)", USAGE_HINT, []);
}
const { wait, maxWaitMs } = resolveWaitMode(flags);
const requireAll = Boolean(flags["require-all"]);
const setExitCode = (c: number) => {
this.process.exitCode = c;
};

const filters = buildDifFilters({
types: flags.type,
Expand All @@ -479,56 +565,72 @@
noUnwind: flags["no-unwind"],
noSources: flags["no-sources"],
});
const files = await scanPaths(paths);
const prepared = await prepareDifs(files, filters);

// For a real upload, resolve org/project and fetch the server's upload
// options up front. The server's advertised `maxFileSize` then gates the
// scan, so a file the server would reject is never read into memory.
// `--no-upload` stays auth-free and uses the generous default cap.
let resolved: Awaited<ReturnType<typeof resolveOrgAndProject>> = null;
let serverOptions: ChunkServerOptions | undefined;
let maxFileSize = DEFAULT_MAX_DIF_SIZE;
if (!flags["no-upload"]) {
resolved = await resolveOrgAndProject({
cwd: this.cwd,
usageHint: USAGE_HINT,
});
if (!resolved) {
throw new ContextError("Organization and project", USAGE_HINT);
}
serverOptions = await getChunkUploadOptions(resolved.org);
if (serverOptions.maxFileSize && serverOptions.maxFileSize > 0) {
maxFileSize = serverOptions.maxFileSize;
}
}

const files = await scanPaths(scanTargets);
const { prepared, oversizedCount } = await prepareDifs(files, filters, {
maxFileSize,
});
const difs = dedupeDifs(
buildDifList(prepared, Boolean(flags["include-sources"]))
);

Check warning on line 596 in src/commands/debug-files/upload.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

Partial size-drop in `filterBySize` (e.g. oversized source bundles) silently exits 0

In `uploadDebugFiles`, `filterBySize` drops any DIF whose `content.length` exceeds `effectiveMaxFileSize` with only a `log.warn`. The `accepted.length === 0` guard that raises `ValidationError` only fires when *every* file is dropped. When some files are accepted and only others are dropped — most notably the in-memory source bundles produced by `--include-sources`, which bypass the scan-time `prepareDifs` size gate — the dropped files never enter `chunked`, so `buildResults` produces no result entry for them. `doUpload` only flags results with `state === "error" || "not_found"`, so `failures.length` stays 0, `setExitCode(1)` is never called, and the command prints "Uploaded N debug file(s)" and exits 0 with no indication the source bundle was dropped.
Comment on lines 594 to 596

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Partial size-drop in filterBySize (e.g. oversized source bundles) silently exits 0

In uploadDebugFiles, filterBySize drops any DIF whose content.length exceeds effectiveMaxFileSize with only a log.warn. The accepted.length === 0 guard that raises ValidationError only fires when every file is dropped. When some files are accepted and only others are dropped — most notably the in-memory source bundles produced by --include-sources, which bypass the scan-time prepareDifs size gate — the dropped files never enter chunked, so buildResults produces no result entry for them. doUpload only flags results with state === "error" || "not_found", so failures.length stays 0, setExitCode(1) is never called, and the command prints "Uploaded N debug file(s)" and exits 0 with no indication the source bundle was dropped.

Evidence
  • filterBySize (debug-files.ts:309-327) drops oversized DIFs with only log.warn and continue, returning the remaining subset.
  • The accepted.length === 0 guard (debug-files.ts:391) only throws ValidationError when all files are dropped; a partial drop passes through silently.
  • buildResults (debug-files.ts:288) maps only over chunked, built from accepted, so dropped files have no entry in the returned results.
  • --include-sources bundles are pushed by buildDifList after prepareDifs ran its scan-time gate, so they reach filterBySize as the only files that can be oversized in the real-upload path; if a bundle exceeds the limit while its parent DIF does not, it is dropped silently.
  • doUpload (upload.ts) computes failures only from state === "error" || "not_found"; with no entry for the dropped bundle, failures.length is 0 and setExitCode(1) is never invoked, so the command exits 0.

Identified by Warden find-bugs · 9LL-87A

const missingIds = missingRequestedIds(flags.id, prepared);
const requireAll = Boolean(flags["require-all"]);

// Dry-run is purely informational: report what would upload (and surface
// size skips) without erroring.
if (flags["no-upload"]) {
return yield* doDryRun(
(c) => {
this.process.exitCode = c;
},
return yield* doDryRun(setExitCode, {
difs,
missingIds,
requireAll
);
requireAll,
oversizedCount,
});
}
Comment thread
sentry[bot] marked this conversation as resolved.

if (difs.length === 0) {
return yield* doNothingToUpload(
(c) => {
this.process.exitCode = c;
},
return yield* doNothingToUpload(setExitCode, {
missingIds,
requireAll
);
requireAll,
oversizedCount,
maxFileSize,
});
}

const resolved = await resolveOrgAndProject({
cwd: this.cwd,
usageHint: USAGE_HINT,
});
// `resolved` is guaranteed set here: the non-dry-run branch above resolved
// it or threw.
if (!resolved) {
throw new ContextError("Organization and project", USAGE_HINT);
}

return yield* doUpload(
(c) => {
this.process.exitCode = c;
},
{
org: resolved.org,
project: resolved.project,
difs,
wait,
maxWaitMs,
missingRequestedIds: missingIds,
requireAll,
}
);
return yield* doUpload(setExitCode, {
org: resolved.org,
project: resolved.project,
serverOptions,
difs,
wait,
maxWaitMs,
missingRequestedIds: missingIds,
requireAll,
});
},
});
19 changes: 19 additions & 0 deletions src/lib/api/chunk-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ export const ChunkServerOptionsSchema = z.object({
chunksPerRequest: z.number(),
/** Maximum total request body size in bytes. */
maxRequestSize: z.number(),
/**
* Maximum size of a single uploaded file in bytes. Omitted or `0` means the
* server advertises no per-file cap, in which case the client falls back to
* {@link DEFAULT_MAX_DIF_SIZE}.
*/
maxFileSize: z.number().optional(),
/**
* Maximum time, in seconds, the server is willing to spend assembling an
* upload. Omitted or `0` means no server-imposed cap; a non-zero value clamps
* the caller's requested wait. Mirrors the legacy `dif_upload` `max_wait`
* semantics.
*/
maxWait: z.number().optional(),
/** Hash algorithm for chunk checksums (always "sha1"). */
hashAlgorithm: z.string(),
/** Maximum concurrent upload requests. */
Expand Down Expand Up @@ -87,6 +100,12 @@ export const ASSEMBLE_POLL_INTERVAL_MS = 1000;
/** Maximum time to wait for assembly. */
export const ASSEMBLE_MAX_WAIT_MS = 300_000;

/**
* Fallback per-file size cap (2 GiB) used when the server advertises no
* `maxFileSize` (i.e. reports `0`). Matches the legacy `DEFAULT_MAX_DIF_SIZE`.
*/
export const DEFAULT_MAX_DIF_SIZE = 2 * 1024 * 1024 * 1024;

/**
* Codecs the CLI knows how to emit, in order of preference.
*
Expand Down
Loading
Loading