Skip to content
Merged
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
49 changes: 26 additions & 23 deletions .lore.md

Large diffs are not rendered by default.

30 changes: 1 addition & 29 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1002,33 +1002,5 @@ duplication and staleness that caused five overlapping PRs to pile up:
<!-- This section is maintained by the coding agent via lore (https://github.com/BYK/loreai) -->
## Long-term Knowledge

### Architecture

<!-- lore:019dabe5-3eee-73a9-83b4-edc56734696a -->
* **env-registry.ts drives --help env var section + docs**: \`src/lib/env-registry.ts\` (\`ENV\_VAR\_REGISTRY\`) is the single source for all env vars the CLI honors. Entries have \`{name, description, example?, defaultValue?, installOnly?, topLevel?, briefDescription?}\`. \`topLevel: true\` + \`briefDescription\` surfaces in \`sentry --help\` Environment Variables section (via \`formatEnvVarsSection()\` in \`help.ts\`) and in \`sentry help --json\` as \`envVars\` array on the full-tree envelope. Docs generator consumes the full registry for \`configuration.md\`. When adding a new env var, add it here with \`installOnly: true\` if install-script-only. Reserve \`topLevel: true\` for core-path vars only (auth, targeting, URL, key display/logging).

<!-- lore:019da557-63d5-7c8a-9ce7-54e992f312ec -->
* **Sentry log IDs are UUIDv7 — enables deterministic retention checks**: Sentry log IDs are UUIDv7 (first 12 hex = ms timestamp, version char \`7\` at pos 13). Traces/event IDs are NOT v7. \`decodeUuidV7Timestamp()\` and \`ageInDaysFromUuidV7()\` in \`src/lib/hex-id.ts\` return null for non-v7, safe to call unconditionally. Enables deterministic 'past retention' messages; wired in \`recoverHexId\` and \`log/view.ts#throwNotFoundError\`. \`RETENTION\_DAYS.log = 90\` in \`src/lib/retention.ts\`; traces/events are \`null\` (plan-dependent). \`LOG\_RETENTION\_PERIOD\` is DERIVED as \`\` \`${RETENTION\_DAYS.log}d\` \`\` — never hardcode \`'90d'\`. Shared hex primitives (\`HEX\_ID\_RE\`, \`SPAN\_ID\_RE\`, \`UUID\_DASH\_RE\`, etc.) live in \`hex-id.ts\`.

<!-- lore:019d4a08-22c3-765b-ba12-d91b29e9d497 -->
* **Three Sentry APIs for span custom attributes with different capabilities**: \*\*Three Sentry span APIs with different capabilities\*\*: (1) \`/trace/{traceId}/\` — hierarchical tree with \`additional\_attributes\`. (2) \`/projects/{org}/{project}/trace-items/{itemId}/\` — single span with ALL attributes. (3) \`/events/?dataset=spans\&field=X\` — list/search. Critical: \`meta.fields\` order is non-deterministic — derive column order from user's \`--field\`/\`-F\` list, not \`Object.keys()\`. See \`orderFieldNames()\` in \`explore.ts\`.

### Gotcha

<!-- lore:019dd024-464d-74e7-b637-c6b87a9d2082 -->
* **api.ts: plain Error throws inside func() bypass CliError handling**: \*\*api.ts: plain Error throws inside func() bypass CliError handling\*\*: \`src/commands/api.ts\` throws plain \`new Error(...)\` in validation paths called from \`func()\` — this bypasses \`app.ts\`'s \`instanceof CliError\` check, causing user to see stack traces AND Sentry bug reports. Fix: use \`ValidationError\` for user-input errors inside \`func()\`. Plain \`Error\` is only OK in Stricli \`parse:\` callbacks where Stricli catches them.

<!-- lore:019da644-b93f-776d-843d-05c3c1d3a193 -->
* **Biome lint differs between local lint:fix and CI lint**: \*\*Biome lint differs between local lint:fix and CI lint\*\*: \`lint:fix\` hides CI issues; always run \`bun run lint\` before pushing. Key gotchas: (1) \`noPrecisionLoss\` on int >2^53 — use \`Number(string)\`. (2) \`noIncrementDecrement\` — use \`i += 1\`. (3) \`noExcessiveCognitiveComplexity\` caps at 15 — extract helpers, don't biome-ignore. (4) \`noUselessUndefined\` then \`noEmptyBlockStatements\` — use \`function noop() {}\`. (5) Plugin forbids raw \`metadata\` table queries — use \`getMetadata\`/\`setMetadata\`/\`clearMetadata\`. Also enforced: \`useBlockStatements\`, \`noNestedTernary\`, \`useAtIndex\`, \`noStaticOnlyClass\`.

<!-- lore:019dc0ef-bb36-7230-be5d-56b536a6de8e -->
* **buildCommand wrapper: loader() returns wrapped async fn, not the generator**: \*\*buildCommand wrapper: loader() returns wrapped async fn, not generator\*\*: \`cmd.loader()\` returns the wrapped async fn, not \`async \*func()\`. Wrapper iterates generator internally and writes to \`ctx.stdout\`. Tests: \`await func.call(ctx, flags, ...args)\` like a promise — don't iterate. Auth guard runs first; \`test/preload.ts:100\` sets fake \`SENTRY\_AUTH\_TOKEN\`. Tests must save/restore only env vars they mutate.

### Pattern

<!-- lore:019dc053-2e98-7b93-80e0-dee06710e849 -->
* **Merging mock.module() test files with static-import counterparts**: \*\*Bun test mocking traps\*\*: (1) \`mock.module()\` for CJS built-ins needs \`default\` re-export + named exports, declared top-level BEFORE \`await import()\`. (2) Convert code-under-test to \`await import()\` when merging mocks — pre-existing static imports won't re-bind. (3) Destructured imports capture binding at load. (4) \`Bun.mmap()\` always PROT\_WRITE — use \`new Uint8Array(await Bun.file(path).arrayBuffer())\` for read-only.

<!-- lore:019dd2ff-f956-7c25-80bd-486c57c2297a -->
* **URL-encoded paren assertions: decode before contains-check**: \*\*URL-encoded paren assertions in tests\*\*: Aggregate field names like \`count()\` become \`count%28%29\` via \`encodeURIComponent\` — use \`expect(decodeURIComponent(url)).toContain("field=count()")\`. Sentry pagination Link header format: \`\<url>; rel="next"; cursor="0:50:0"\` — cursor is in a separate attribute, NOT in URL query. Use \`parseSentryLinkHeader()\` from \`src/lib/api/infrastructure.ts\` to extract.
For long-term knowledge entries managed by [lore](https://github.com/BYK/loreai) (gotchas, patterns, decisions, architecture), see [`.lore.md`](.lore.md) in the project root.
<!-- End lore-managed section -->
171 changes: 128 additions & 43 deletions src/commands/sourcemap/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
buildIgnoreMatcher,
diagnoseEmptyDiscovery,
discoverFilePairs,
type InjectResult,
injectDirectory,
} from "../../lib/sourcemap/inject.js";

Expand Down Expand Up @@ -105,6 +106,102 @@ function stripPrefix(path: string, prefix: string): string {
return path;
}

/** Context shared across artifact-file construction for one upload. */
type ArtifactContext = {
/** Absolute upload directory (paths are made relative to this). */
resolvedDir: string;
/** URL prefix applied to every artifact URL (e.g. `"~/"`). */
urlPrefix: string;
/** Directory prefix to strip from relative paths (may be empty). */
pathPrefixToStrip: string;
/** True when `--no-rewrite` is set (upload as-is, no debug IDs injected). */
noRewrite: boolean;
};

/** Compute a JS file's URL-space relative path (post-strip, forward slashes). */
function jsRelativePath(jsPath: string, ctx: ArtifactContext): string {
const rel = relative(ctx.resolvedDir, jsPath).replaceAll("\\", "/");
return ctx.pathPrefixToStrip ? stripPrefix(rel, ctx.pathPrefixToStrip) : rel;
}

/**
* Build the `minified_source` + `source_map` artifact pair for a discovered
* file, dispatching on whether the sourcemap is inline or an external file.
*/
function buildArtifactPair(
result: InjectResult,
ctx: ArtifactContext
): ArtifactFile[] {
const { jsPath, map, debugId } = result;
const jsRelative = jsRelativePath(jsPath, ctx);
const debugIdField = debugId ? { debugId } : {};

if (map.kind === "inline") {
// Resolve the map bytes to upload:
// - rewrite path: inject produced the debug-ID-injected map.
// - --no-rewrite: upload the original inline map decoded at discovery.
// - rewrite aborted (directive not found): no `injectedMapContent` and not
// --no-rewrite → the JS was left unmodified, so upload nothing for it
// rather than attaching a debug ID / map the bundle doesn't carry.
let content = result.injectedMapContent;
if (!content) {
if (ctx.noRewrite) {
content = Buffer.from(map.decoded.json);
} else {
return [];
}
}
// Synthetic map URL — the server matches by debug ID, so the filename is
// cosmetic. Derived from the (post-strip) JS URL.
const mapRelative = `${jsRelative}.map`;
return [
{
path: jsPath,
...debugIdField,
type: "minified_source",
url: `${ctx.urlPrefix}${jsRelative}`,
sourcemapFilename: posixRelative(posixDirname(jsRelative), mapRelative),
},
{
// path is informational for inline maps; content is used instead.
path: jsPath,
content,
...debugIdField,
type: "source_map",
url: `${ctx.urlPrefix}${mapRelative}`,
},
];
}

// External map on disk.
let mapRelative = relative(ctx.resolvedDir, map.mapPath).replaceAll(
"\\",
"/"
);
if (ctx.pathPrefixToStrip) {
mapRelative = stripPrefix(mapRelative, ctx.pathPrefixToStrip);
}
return [
{
path: jsPath,
// Empty debugId when --no-rewrite: files uploaded without debug IDs,
// relying on release/URL-based matching instead.
...debugIdField,
type: "minified_source",
url: `${ctx.urlPrefix}${jsRelative}`,
// Sourcemap header is resolved relative to the JS file's URL. Compute
// from post-strip URL-space paths so --strip-prefix doesn't break it.
sourcemapFilename: posixRelative(posixDirname(jsRelative), mapRelative),
},
{
path: map.mapPath,
...debugIdField,
type: "source_map",
url: `${ctx.urlPrefix}${mapRelative}`,
},
];
}

export const uploadCommand = buildCommand({
docs: {
brief: "Upload sourcemaps to Sentry",
Expand Down Expand Up @@ -281,8 +378,14 @@ export const uploadCommand = buildCommand({
}
const { org, project } = resolved;

const results = flags["no-rewrite"]
? pairs.map((p) => ({ ...p, injected: false, debugId: "" }))
const results: InjectResult[] = flags["no-rewrite"]
? pairs.map((p) => ({
jsPath: p.jsPath,
map: p.map,
mapPath: p.map.kind === "external" ? p.map.mapPath : undefined,
injected: false,
debugId: "",
}))
: await injectDirectory(dir, {
extensions,
ignoreMatcher,
Expand All @@ -300,49 +403,24 @@ export const uploadCommand = buildCommand({
pathPrefixToStrip = `${pathPrefixToStrip}/`;
}
if (flags["strip-common-prefix"]) {
const allRelative = results.flatMap(({ jsPath, mapPath }) => [
relative(resolvedDir, jsPath).replaceAll("\\", "/"),
relative(resolvedDir, mapPath).replaceAll("\\", "/"),
]);
// Only the JS path participates for inline maps (no standalone .map file).
const allRelative = results.flatMap((r) => {
const rels = [relative(resolvedDir, r.jsPath).replaceAll("\\", "/")];
if (r.mapPath) {
rels.push(relative(resolvedDir, r.mapPath).replaceAll("\\", "/"));
}
return rels;
});
pathPrefixToStrip = computeCommonPrefix(allRelative);
}

const artifactFiles: ArtifactFile[] = results.flatMap(
({ jsPath, mapPath, debugId }) => {
// Normalize to forward slashes for URLs (handles Windows backslashes)
let jsRelative = relative(resolvedDir, jsPath).replaceAll("\\", "/");
let mapRelative = relative(resolvedDir, mapPath).replaceAll("\\", "/");

if (pathPrefixToStrip) {
jsRelative = stripPrefix(jsRelative, pathPrefixToStrip);
mapRelative = stripPrefix(mapRelative, pathPrefixToStrip);
}

// Sourcemap header is resolved relative to the JS file's URL.
// Compute from post-strip URL-space paths so --strip-prefix
// doesn't break the reference.
const sourcemapRef = posixRelative(
posixDirname(jsRelative),
mapRelative
);
return [
{
path: jsPath,
// Empty debugId when --no-rewrite: files uploaded without debug IDs,
// relying on release/URL-based matching instead.
...(debugId ? { debugId } : {}),
type: "minified_source" as const,
url: `${urlPrefix}${jsRelative}`,
sourcemapFilename: sourcemapRef,
},
{
path: mapPath,
...(debugId ? { debugId } : {}),
type: "source_map" as const,
url: `${urlPrefix}${mapRelative}`,
},
] satisfies ArtifactFile[];
}
const artifactFiles: ArtifactFile[] = results.flatMap((result) =>
buildArtifactPair(result, {
resolvedDir,
urlPrefix,
pathPrefixToStrip,
noRewrite: flags["no-rewrite"] ?? false,
})
Comment thread
cursor[bot] marked this conversation as resolved.
);

await uploadSourcemaps({
Expand All @@ -353,12 +431,19 @@ export const uploadCommand = buildCommand({
files: artifactFiles,
});

// Count actually-uploaded pairs (one minified_source entry per pair).
// buildArtifactPair returns no entries for inline pairs whose rewrite was
// aborted, so this excludes skipped pairs that results.length would count.
const filesUploaded = artifactFiles.filter(
(f) => f.type === "minified_source"
).length;

yield new CommandOutput<UploadCommandResult>({
org,
project,
release: flags.release,
dist: flags.dist,
filesUploaded: results.length,
filesUploaded,
});
},
});
15 changes: 13 additions & 2 deletions src/lib/api/sourcemaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,18 @@ export type AssembleResponse = z.infer<typeof AssembleResponseSchema>;

/** A source file to include in the artifact bundle. */
export type ArtifactFile = {
/** Filesystem path to the file. */
/**
* Filesystem path to the file. Read from disk unless {@link ArtifactFile.content}
* is set; for inline sourcemaps (which have no `.map` file) this is
* informational only.
*/
path: string;
/**
* In-memory file content. When set, {@link buildArtifactBundle} uses this
* instead of reading from `path`. Used for inline sourcemaps that have no
* standalone `.map` file on disk.
*/
content?: Buffer;
/** Debug ID injected into this file (from {@link injectDebugId}). Omitted when uploading without rewriting. */
debugId?: string;
/**
Expand Down Expand Up @@ -359,7 +369,8 @@ export async function buildArtifactBundle(

for (const file of files) {
const bundlePath = urlToBundlePath(file.url);
const content = await readFile(file.path);
// Prefer in-memory content (inline sourcemaps); otherwise read from disk.
const content = file.content ?? (await readFile(file.path));
await zip.addEntry(bundlePath, content);
}

Expand Down
Loading
Loading