Skip to content

analyze: add --json output, fixes #9992 - #10001

Open
MannXo wants to merge 2 commits into
borgbackup:masterfrom
MannXo:feat/9992-analyze-json
Open

analyze: add --json output, fixes #9992#10001
MannXo wants to merge 2 commits into
borgbackup:masterfrom
MannXo:feat/9992-analyze-json

Conversation

@MannXo

@MannXo MannXo commented Aug 1, 2026

Copy link
Copy Markdown

Adds --json to borg analyze, as proposed in #9992.

The numbers are emitted as raw byte values, for the default mode as well as for --by-name. Output goes to stdout as one JSON object, like the other --json commands.

$ borg analyze -a 'sh:alpha*' --json
{
    "dedup_size": {
        "considered_archives": 2,
        "deduplicated": {"source_size": 3000, "stored_size": 3794},
        "exclusive": {"source_size": 1000, "stored_size": 3398},
        "missing_chunks": 0,
        "total_archives": 3,
        "total_chunks": 13,
        "unreferenced": {"chunks": 0, "stored_size": 0},
        "whole_repository": false
    },
    "encryption": {"encryption": "aes256-ocb", "id_hash": "sha256"},
    "hotspots": [{"path": "home/user/src/sub", "size": 1000}],
    "repository": {"id": "...", "last_modified": "...", "location": "..."}
}

(Reformatted to fit: the real output puts one key per line. The numbers are from the fixture repository described under Testing, with the repository id and paths genericized.)

Decisions worth a second opinion

  • No compression factor. The text report shows stored / source, but in JSON that is derivable, and the "n/a" the text uses where there is no source size is not a useful JSON value. Say the word and I will add it.
  • hotspots is null, not [], when fewer than two archives matched. The hot spots were then not computed at all, which is different from having computed them and found no churn. --by-name omits the key entirely, as it does no hot-spot analysis.
  • exclusive is absent when whole_repository is true. That mirrors the text report, which suppresses the line because every referenced chunk is then trivially exclusive to the set.
  • missing_chunks is included although the text report only logs it as a warning. Monitoring wants it, and dropping it would leave a JSON consumer unable to see that the numbers are based on an incomplete index.
  • Key names follow the text report's column names: source_size and stored_size.

Refactor

The analysis methods now return their numbers, and printing moved into report_dedup_size(), report_by_name() and report_hotspots(), so the text report and the JSON come from one source of truth rather than two.

The text output is unchanged, byte for byte. I checked that. I built a fixture repository (2 archives named alpha, 1 named beta, sharing one file, with churn in a subdirectory), captured analyze in six variants such as: whole repository, a matching set, a single archive, --by-name, the --by-name + filter error, and the no-match error — on master and on this branch, and diffed the captured stdout and stderr:

$ diff -r before/ after/ && echo "TEXT OUTPUT IDENTICAL"
TEXT OUTPUT IDENTICAL

Testing

4 new tests in analyze_cmd_test.py, covering the default mode, the whole-repository case, hot spots (both computed and not computed), and --by-name.

$ pytest --benchmark-skip -q -n auto src/borg/testsuite/archiver/analyze_cmd_test.py
11 passed in 2.98s

$ pytest --benchmark-skip -q -n auto          # this branch
2201 passed, 616 skipped in 112.44s (0:01:52)

$ pytest --benchmark-skip -q -n auto          # master, for comparison
2197 passed, 616 skipped in 105.44s (0:01:45)

Exactly the 4 new tests, no new skips. The skips are the fakeroot, FUSE and Linux-only ones; I ran this on macOS, so CI covers those.

Each new test was checked by reverting the behaviour it covers, one sabotage at a time, and confirming the intended test fails and then passes again:

Sabotage Test that failed
always emit exclusive, also for the whole repository test_analyze_json_whole_repository
hotspots = [] instead of null when not computed test_analyze_json_hotspots
emit formatted sizes instead of raw byte values test_analyze_json (+ 6 text-report tests)
sort the --by-name rows smallest first test_analyze_json_by_name
count shared chunks against the first name test_analyze_by_name, test_analyze_json_by_name
add the shared size to the total a second time test_analyze_by_name, test_analyze_json_by_name
print the text report in JSON mode too test_analyze_json, and 2 more
put the --by-name result under the dedup_size key test_analyze_json_by_name

The dedup-size test does not hardcode the compression-dependent stored sizes. It asserts that both columns of the corresponding text row are format_file_size() of the JSON values, which pins the "same numbers the text report is rendered from" property directly.

black --check, ruff check and pre-commit run are clean on the changed files. cd docs && make html builds, and the new :ref: resolves (href="../internals/frontends.html#json-output").

Docs

  • docs/internals/frontends.rst gains an Archive Analysis subsection describing every key, plus an example. The example is real captured output with the repository id and paths genericized.
  • The epilog gains a short JSON output paragraph and a :ref: to that section, with a plain-text substitution in rst_plain_text_references so borg analyze --help renders it as a sentence rather than leaking markup.
  • docs/usage/analyze.rst.inc is regenerated (python scripts/make.py build_usage).

I did not regenerate docs/man/borg-analyze.1. It is stale from an earlier change (t still documents only the hot spots and none of the dedup-size report) so regenerating it would fold an unrelated docs catch-up into this PR. Happy to add it here, or in a separate commit, whichever you prefer.

I also have not touched CHANGES.rst, since recent feature commits do not.


Claude Code assisted with this change(Opus 5). I verified every change including the byte-for-byte text-output comparison, the per-test sabotage table and the docs build above are the evidence, and the JSON examples are captured output, not written by hand.

Every other status-type command (info, repo-info, repo-list, list, diff,
prune) can emit JSON, so tooling does not have to scrape their text. borg
analyze was the odd one out, although its numbers are exactly what
monitoring wants.

--json emits the numbers the text report is rendered from, as raw byte
values, for the default mode (dedup_size, hotspots) as well as for
--by-name (by_name). The compression factor is left out: it is
stored_size / source_size, and "n/a" is not a useful JSON value.

To keep one source of truth, the analysis methods now return their
numbers and the printing moved into report_*() methods that format them.
The text output is unchanged, byte for byte.

hotspots is null rather than empty when fewer than two archives matched:
the hot spots were then not computed at all, which is different from
having computed them and found nothing.
@ThomasWaldmann

Copy link
Copy Markdown
Member

Opus being dramatic as usual. Sabotage! :-)

@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.12%. Comparing base (a293a67) to head (0550944).
⚠️ Report is 79 commits behind head on master.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #10001      +/-   ##
==========================================
+ Coverage   85.88%   86.12%   +0.23%     
==========================================
  Files          95       96       +1     
  Lines       17088    17354     +266     
  Branches     2616     2655      +39     
==========================================
+ Hits        14676    14946     +270     
+ Misses       1674     1667       -7     
- Partials      738      741       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@ThomasWaldmann

Copy link
Copy Markdown
Member

Windows CI is failing due to path normalization.

The test rebuilt the expected hot-spot path from the input directory,
stripping a leading slash. Archived paths are normalized, and on Windows
that also drops the drive colon (C:\Users -> C/Users), so the expectation
read D:/a/... where borg had stored D/a/....

Assert the size of the input directory's hot spot by path suffix, like
the text-report test above already does, and check the paths against
what the text report prints instead of rebuilding them.
@MannXo

MannXo commented Aug 3, 2026

Copy link
Copy Markdown
Author

Windows CI is failing due to path normalization.

reflected

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.

2 participants