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
15 changes: 10 additions & 5 deletions src/commands/traces/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,20 @@ export async function runExport(deps: ExportDeps): Promise<void> {
logProgress(`Writing bundle to ${outputDir} …`, writers);
mkdirSync(outputDir, { recursive: true });

// A flagged trace adds a 5th file, `finding.json` (the full FindingDetail —
// finding id + per-detector results + RCA). Computed up front so the manifest
// written to disk, the stderr summary, and the --json summary all report the
// exact same file list — the server's manifest doesn't know about the finding,
// which the CLI fetches separately, so it must be patched here.
const files: string[] = finding !== null ? [...BUNDLE_FILES, "finding.json"] : [...BUNDLE_FILES];

const contents: Record<(typeof BUNDLE_FILES)[number], unknown> = {
"trace.json": response.trace,
"spans.json": response.spans,
"git_context.json": response.git_context,
"manifest.json": response.manifest,
// Spread-then-override preserves key order and leaves bundle_version,
// project_id, trace_id untouched; only `files` reflects the finding.
"manifest.json": { ...response.manifest, files },
};
// The trace is fetched before the directory is created, so a fetch failure
// leaves nothing on disk. A mid-write I/O error here can still leave a partial
Expand All @@ -90,13 +99,9 @@ export async function runExport(deps: ExportDeps): Promise<void> {
writeFileSync(join(outputDir, file), toJsonFile(contents[file]), "utf8");
}

// A flagged trace adds a 5th file, `finding.json` (the full FindingDetail —
// finding id + per-detector results + RCA). The four core files stay unchanged.
const files: string[] = [...BUNDLE_FILES];
const findingPath = join(outputDir, "finding.json");
if (finding !== null) {
writeFileSync(findingPath, toJsonFile(finding), "utf8");
files.push("finding.json");
} else {
// A --force overwrite of a directory from an earlier *flagged* export could
// leave behind a finding.json for a different trace; remove it so the bundle
Expand Down
90 changes: 90 additions & 0 deletions tests/commands/traces.export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,56 @@ describe("runExport", () => {
expect(err.data).not.toContain("Flagged");
});

it("patches manifest.json's files to include finding.json for a flagged trace, matching the --json summary", async () => {
const response = makeResponse();
const outputDir = join(tmpRoot, "bundle");
const { writers, out } = makeWriters();

await runExport({
client: fakeClient(response, makeFinding()),
traceId: "abc123",
outputDir,
force: false,
json: true,
writers,
});

const manifest = JSON.parse(readFileSync(join(outputDir, "manifest.json"), "utf8"));
const expectedFiles = [
"trace.json",
"spans.json",
"git_context.json",
"manifest.json",
"finding.json",
];
expect(manifest.files).toEqual(expectedFiles);
const parsed = JSON.parse(out.data);
expect(manifest.files).toEqual(parsed.files);
// Everything else passes through untouched.
expect(manifest.bundle_version).toBe(response.manifest.bundle_version);
expect(manifest.project_id).toBe(response.manifest.project_id);
expect(manifest.trace_id).toBe(response.manifest.trace_id);
});

it("writes manifest.json byte-identical to the server's manifest for an unflagged trace", async () => {
const response = makeResponse();
const outputDir = join(tmpRoot, "bundle");
const { writers } = makeWriters();

await runExport({
client: fakeClient(response, null),
traceId: "abc123",
outputDir,
force: false,
json: false,
writers,
});

const raw = readFileSync(join(outputDir, "manifest.json"), "utf8");
expect(raw).toBe(`${JSON.stringify(response.manifest, null, 2)}\n`);
expect(JSON.parse(raw)).toEqual(response.manifest);
});

it("writes no finding.json when the finding lookup fails (best-effort)", async () => {
const response = makeResponse();
const outputDir = join(tmpRoot, "bundle");
Expand Down Expand Up @@ -498,6 +548,46 @@ describe("runExport", () => {
expect(existsSync(join(outputDir, "trace.json"))).toBe(true);
});

it("--force re-export from flagged to unflagged drops finding.json and shrinks manifest.files back to 4", async () => {
const response = makeResponse();
const outputDir = join(tmpRoot, "bundle");
const { writers } = makeWriters();

// Seed the directory as a prior *flagged* export would have left it: a
// finding.json plus a 5-entry manifest.
await runExport({
client: fakeClient(response, makeFinding()),
traceId: "abc123",
outputDir,
force: false,
json: false,
writers,
});
expect(existsSync(join(outputDir, "finding.json"))).toBe(true);
expect(JSON.parse(readFileSync(join(outputDir, "manifest.json"), "utf8")).files).toContain(
"finding.json",
);

// Re-export the same trace, now unflagged, with --force.
await runExport({
client: fakeClient(response, null),
traceId: "abc123",
outputDir,
force: true,
json: false,
writers,
});

expect(existsSync(join(outputDir, "finding.json"))).toBe(false);
const manifest = JSON.parse(readFileSync(join(outputDir, "manifest.json"), "utf8"));
expect(manifest.files).toEqual([
"trace.json",
"spans.json",
"git_context.json",
"manifest.json",
]);
});

it("propagates a fetch failure and creates no bundle dir or files", async () => {
const outputDir = join(tmpRoot, "bundle");
const { writers } = makeWriters();
Expand Down
Loading