Skip to content

feat(admin): CSV export for growth-report (Glue/Athena/QuickSight) - #27

Merged
dsblank merged 31 commits into
mainfrom
feat/growth-report-csv-export
Sep 10, 2026
Merged

dsblank merged 31 commits into
mainfrom
feat/growth-report-csv-export

Conversation

@LeoRoccoBreedt

@LeoRoccoBreedt LeoRoccoBreedt commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

User description

Why

A customer runs cometx admin growth-report monthly and feeds the results into a dashboard: CSV → S3 → AWS Glue tables → Athena → QuickSight. Today the command only emits a self-contained HTML page, which that pipeline can't consume. They asked for CSV output of the aggregated data, plus a definite schema so Glue tables can be created against it once and not break on later runs.

What this adds

Three new flags on growth-report:

Flag Effect
--csv-dir DIR Also write Glue-ready CSV fact tables into DIR
--no-html Skip the HTML (use with --csv-dir for CSV-only runs)
--chargeback-report FILE Read chargeback JSON from a local file instead of calling the admin API
cometx admin growth-report --csv-dir ./out
cometx admin growth-report --csv-dir ./out --no-html
cometx admin growth-report --chargeback-report report.json --csv-dir ./out

Output

File Grain
growth_users.csv one row per non-deleted user
growth_workspaces.csv one row per workspace (exact totals)
growth_org_kpis.csv one row per metric, long format

Design decisions (agreed with the customer)

Full-grain fact tables, not the HTML's derived views. The report's leaderboards and KPIs are top-N slices and sums of these same records. Exporting the full grain lets QuickSight reproduce every chart and answer questions the HTML doesn't, without a new cometx release per requested cut.

No workspace column on the users table. Chargeback reports experiment_count / data_logged_mb / opik_span_count per user, not per (user, workspace). A row per (user, workspace) would repeat each user's totals and make SUM() over-count. One row per user means a plain SUM is correct with no DISTINCT. Exact per-workspace totals live in growth_workspaces.csv. Documented trade-off: per-workspace user breakdowns aren't answerable from this export.

Org KPIs are long-format (metric_name / metric_value) so new metrics arrive as new rows — the Glue schema never changes and existing partitions stay readable.

CSV is built from the parsed records, never from report_data. This is the load-bearing decision: report_data holds display-formatted strings (_num() inserts thousands separators, rates carry %), which would make a Glue crawler type those columns as string and silently break aggregation in the dashboard.

Validated against a real deployment

Run against a live self-hosted EKS cluster (408 users, 523 workspaces):

  • 298 multi-workspace users each produced exactly one row — the no-duplication guarantee, on production data. Under a (user, workspace) grain that would have been ~700 rows with inflated sums.
  • Row counts reconciled exactly with the source on both tables.
  • No thousands separators, % suffixes, or other Glue-hostile formatting in any value.

Compatibility

Existing behavior is unchanged when the new flags aren't passed — enforced by a test asserting the HTML is byte-identical (frozen clock, SHA-256) with and without --csv-dir.

Testing

240 unit tests pass (baseline was 207). Coverage includes: one row per multi-workspace user, deleted excluded / suspended flagged, epoch-ms → ISO dates, None → empty field (distinct from a real 0), no separators or % in any value, header written even with zero rows, non-ASCII round-trip under a C locale, and non-zero exit on write failure.

Notes for the reviewer

Two pre-existing issues fixed here, both found while running against the real cluster:

  • CSV write failures exited 0. An unwritable --csv-dir printed an error but reported success — the worst shape for a scheduled monthly job, which would report success while shipping nothing. Now exits non-zero.
  • The admin error handler crashed on a broken __str__. comet_ml.exceptions.NotFound.__str__ returns None when the 404 body isn't JSON (an HTML page from a proxy/ingress), so str(exc) raised TypeError and replaced the real HTTP error with a traceback from the handler itself. A 404 now prints ERROR: NotFound (HTTP 404).

Two documented quirks a reviewer will notice in the data:

  • total_users won't equal the users-table row count. The KPI excludes suspended users; the table excludes deleted ones. On the real deployment: 434 vs 408. A deleted_users KPI makes this reconcile explicitly (434 − 27 + 1 = 408). This is pre-existing behavior — adoption_stats is untouched by this PR and the HTML report has shown 434 all along. Changing the metric's semantics is deliberately deferred as a separate decision.
  • Service accounts always classify as zero. Not a bug: service accounts aren't licensed users, so they never appear in the chargeback roster. The admin endpoint's 7 accounts on the test cluster matched none of the 435 roster users. The personal-vs-service split is therefore structurally one-sided. Surfacing service accounts is planned as follow-up work; note the endpoint returns an inventory (name, owner, workspace scope) with no usage metrics, so it cannot fix the split.

Implementation notes:

  • admin_growth_csv.py deliberately does not import from admin_growth_report.py (that module imports from admin_growth_users.py; importing back would be circular). It takes already-parsed records as arguments.
  • report_date is the UTC run date, injectable as a parameter for tests but intentionally not exposed as a CLI flag.
  • The CSV block runs before HTML generation, so a CSV failure suppresses the HTML — fail-fast, and the exit code now reports it truthfully.
  • deleted_at is emitted as a column but is always empty, since deleted users remain filtered out. It exists so the column is typed for a Glue crawler and the schema needn't change if that policy is revisited.

🤖 Generated with Claude Code


Generated description

Below is a concise technical summary of the changes proposed in this PR:

graph LR
admin_("admin"):::modified
generate_growth_report_("generate_growth_report"):::modified
GrowthReporter_build_("GrowthReporter.build"):::modified
ADMIN_API_("ADMIN_API"):::modified
GrowthReporter_assemble_report_data_("GrowthReporter._assemble_report_data"):::modified
collect_org_kpis_("collect_org_kpis"):::added
write_growth_csvs_("write_growth_csvs"):::added
stage_csv_("_stage_csv"):::added
short_api_error_("_short_api_error"):::modified
exception_text_("exception_text"):::added
admin_ -- "Adds CSV-only, HTML suppression, and preloaded chargeback report options." --> generate_growth_report_
generate_growth_report_ -- "Passes optional chargeback payload and captures parsed export data." --> GrowthReporter_build_
GrowthReporter_build_ -- "Skips API fetch for local payloads and improves endpoint error reporting." --> ADMIN_API_
GrowthReporter_build_ -- "Adds exclusion counts and records parsed users, workspaces, and KPIs." --> GrowthReporter_assemble_report_data_
GrowthReporter_assemble_report_data_ -- "Exports normalized user, workspace, adoption, growth, and account KPIs." --> collect_org_kpis_
generate_growth_report_ -- "Writes three Glue-ready CSV fact tables with report dates." --> write_growth_csvs_
write_growth_csvs_ -- "Stages complete tables privately before atomic publication." --> stage_csv_
short_api_error_ -- "Prevents broken exception stringification from masking HTTP errors." --> exception_text_
classDef added stroke:#15AA7A
classDef removed stroke:#CD5270
classDef modified stroke:#EDAC4C
linkStyle default stroke:#CBD5E1,font-size:13px
Loading

Add Glue-ready CSV fact-table export to growth-report, supporting users, workspaces, long-format KPIs, local chargeback snapshots, CSV-only execution, schema-safe numeric normalization, scope provenance, and staged publication while preserving existing HTML output. Harden admin command error reporting and exit statuses across API, parsing, output, interruption, and malformed-exception paths, with documentation and extensive regression coverage.

TopicDetails
Reliable CLI execution Harden unattended admin workflows by propagating non-zero failures for report generation, invalid output paths, malformed snapshots, API/parsing degradation, and interrupts; render exceptions safely even when __str__ is broken while retaining HTML-only degradation when CSV export is not requested.
Modified files (6)
  • README-ADMIN.md
  • cometx/cli/admin.py
  • cometx/cli/admin_growth_report.py
  • cometx/utils.py
  • tests/unit/test_admin_exit_codes.py
  • tests/unit/test_admin_growth_report.py
Latest Contributors(2)
UserCommitDate
doug@comet.comrefactor(admin): drop ...September 10, 2026
leobreedt@gmail.comfix(admin): --exclude-...September 10, 2026
CSV data pipeline Export parsed chargeback records as stable, UTF-8, Glue-compatible users, workspaces, and KPI tables; normalize dates and numeric values, preserve null semantics, record filtering provenance, support local snapshots and CSV-only runs, and stage outputs before publication.
Modified files (6)
  • README-ADMIN.md
  • cometx/cli/admin.py
  • cometx/cli/admin_growth_csv.py
  • cometx/cli/admin_growth_report.py
  • tests/unit/test_admin_growth_csv.py
  • tests/unit/test_admin_growth_report.py
Latest Contributors(2)
UserCommitDate
doug@comet.comfix(admin): keep stage...September 10, 2026
leobreedt@gmail.comfix(admin): --exclude-...September 10, 2026
Artifact hygiene Protect repository hygiene and operational data boundaries by ignoring generated reports, chargeback snapshots, CSV artifacts, and local documentation/sample directories so customer usage data and working files are not committed.
Modified files (1)
  • .gitignore
Latest Contributors(2)
UserCommitDate
leobreedt@gmail.comchore: ignore growth_r...September 10, 2026
doug.blank@gmail.comInitial commitDecember 16, 2022
Review this PR on Baz

LeoRoccoBreedt and others added 8 commits September 3, 2026 13:42
Pure functions that turn parsed UserRecord/WorkspaceRecord objects into
Glue-ready CSV rows for three flat tables (users, workspaces, org KPIs).
Built from the parsed records rather than the HTML report's display-
formatted report_data so numeric columns type correctly in a Glue crawler.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wires the CLI up to the csv_dir/no_html/chargeback kwargs generate_growth_report
already supports. Adds argparse validation for --no-html requiring --csv-dir,
and a distinct error for an unreadable/malformed --chargeback-report file
(separate from the "requires an admin API key" message). Also strengthens
test_html_still_written_when_csv_dir_absent to assert byte-identical HTML
output with/without --csv-dir under a frozen clock, rather than just file
existence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Regenerate the customer-facing sample CSVs to match the final,
customer-agreed schema (report_date instead of report_month, no
workspace column in growth_users.csv, plus em_last_used_at /
opik_last_used_at), remove the superseded Option B/C samples, rewrite
docs/csv-samples/README.md around the single agreed schema, and add a
CSV export subsection to README-ADMIN.md documenting the --csv-dir /
--no-html / --chargeback-report flags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two pre-merge review fixes for the growth-report CSV export.

1. CSV write failures exited 0. The generic `except Exception` handler in
   the growth-report dispatch printed the error and returned, so an
   unwritable --csv-dir (or one pointing at a file) reported success while
   shipping nothing -- the worst failure shape for the monthly scheduled
   run this feature is built for. Now calls sys.exit(1), matching the
   sibling GrowthReportError handler, and preserving --debug tracebacks.

2. CSVs were written without an explicit encoding, falling back to the
   platform locale. Under LANG=C (typical for cron/systemd) a single
   non-ASCII username raised UnicodeEncodeError mid-write, leaving a
   truncated CSV for Glue to crawl. Now opens with encoding="utf-8",
   matching admin_growth_render.py and utils.py.

Tests: a regression test driving admin() to a non-zero SystemExit with
--csv-dir pointing at a file; a UTF-8 round-trip through DictReader; and
a subprocess test that runs the write in a genuinely ASCII locale (the
bug cannot be reproduced in-process -- open() resolves its default
encoding in C, and PEP 538 coercion turns LANG=C back into UTF-8).

A third reported finding (total_users disagreeing with the users table)
is deliberately NOT addressed here: the prescribed fix does not achieve
its stated goal. See the task report for the analysis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… count

`total_users` in growth_org_kpis.csv excludes suspended accounts (it is the
licensing/adoption denominator behind active_users_pct), while
growth_users.csv carries one row per non-deleted user, suspended included.
The two therefore differ for any org with suspended or deleted accounts.
This is intentional, so document it rather than changing the metric --
the users table exposes is_suspended, so a dashboard can reproduce either
definition from the row data.

Adds a caveat to the CSV export section of README-ADMIN.md (matching the
existing "no workspace column" caveat) and a two-sentence note to the
customer-facing docs/csv-samples/README.md.

Also corrects the sample KPI values, which were hand-authored on the naive
assumption and contradicted the behavior now documented: with 6 user rows
of which 1 is suspended, total_users is 5 (not 6) and active_users_pct is
80.0 (not 66.7). new_users_in_window_pct was likewise wrong at 16.7 -- the
real `_window_growth` divides new-in-window by accounts pre-dating the
window (1/5 = 20.0), not by the total. The samples now demonstrate the
documented rule instead of teaching against it. Regenerated the CSVs and
rebuilt the zip.

No behavior change: total_users, active_users_60d, active_users_pct and
adoption_stats are untouched. Full suite 235 passed, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/ holds design specs, plans, customer correspondence and
customer-deliverable CSV samples — working artifacts, not product code.
This is a public repo, so they stay local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LeoRoccoBreedt
LeoRoccoBreedt force-pushed the feat/growth-report-csv-export branch from c980a0a to 6532eb2 Compare September 3, 2026 19:45
LeoRoccoBreedt and others added 4 commits September 3, 2026 21:53
The C-locale regression test passed the non-ASCII payload through
`python -c`, so the subprocess had to decode its own command line using
the ASCII locale the test deliberately sets. On Linux CI that fails
before any of our code runs ("Unable to decode the command from the
command line"); macOS happened to tolerate it.

Write the script to a UTF-8 file with a coding declaration and pass the
path instead: argv stays pure ASCII while the source is still read as
UTF-8. Verified still red/green -- reverting the `encoding="utf-8"` fix
in _write_csv reproduces the UnicodeEncodeError the test guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`comet_ml.exceptions.NotFound.__str__` returns None when the 404 body is
not JSON -- an HTML error page from a proxy or ingress, for instance. The
admin error handler then died with "TypeError: __str__ returned
non-string (type NoneType)", replacing the real HTTP error with a
traceback from the handler itself. An operator hitting a wrong or
unavailable endpoint saw no usable diagnosis.

Route the handler through `_exception_text`, which falls back to the
exception class name plus the response status code when `str(exc)` is
empty or raises. A 404 on the chargeback endpoint now prints
"ERROR: NotFound (HTTP 404)".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Running the helper script by path sets sys.path[0] to the script's own
directory (tmp_path) rather than the CWD, so `import cometx` failed on CI
with ModuleNotFoundError. It passed locally only because an editable
install put the package on sys.path anyway -- exactly the difference
between a dev checkout and a clean CI checkout.

Pass the repo root explicitly via PYTHONPATH, derived from the imported
package rather than hardcoded. Verified against a simulated CI setup
(subprocess run from a different cwd with no editable install visible),
and still red/green: reverting the encoding="utf-8" fix reproduces the
UnicodeEncodeError.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Running against a real deployment surfaced a confusing gap: the
total_users KPI read 434 while growth_users.csv had 408 rows, with
nothing in the data explaining the difference. The two exclude different
populations -- total_users excludes suspended accounts, the users table
excludes deleted ones -- so a dashboard showed two tiles disagreeing by
27 with no way to reconcile them.

Add a `deleted_at` column (appended last, always empty since deleted
users remain filtered out, so no existing sum changes meaning) and a
`deleted_users` KPI. The two files now reconcile explicitly:

  total_users - deleted_users + suspended = users-table row count

verified as 434 - 27 + 1 = 408 against the real data.

No change to adoption_stats or total_users semantics -- that remains a
separate decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LeoRoccoBreedt
LeoRoccoBreedt marked this pull request as ready for review September 4, 2026 13:24
Comment thread cometx/cli/admin.py Outdated
Comment thread cometx/cli/admin_growth_csv.py
Comment thread cometx/cli/admin_growth_csv.py
Comment thread cometx/cli/admin_growth_csv.py
Comment thread cometx/cli/admin_growth_csv.py Outdated
Comment thread cometx/cli/admin_growth_csv.py Outdated
Comment thread cometx/cli/admin_growth_report.py
Four fixes from the automated review, two of which were real bugs I
reproduced before changing anything.

1. Reconciliation broke on deleted+suspended users (logic bug). The
   documented identity `total_users - deleted_users + suspended` assumed
   the two exclusions were disjoint. An account carrying BOTH flags is
   absent from total_users (suspended) AND counted in deleted_users
   (deleted), so subtracting removed it twice: with 1 live, 1 suspended,
   1 deleted and 1 deleted+suspended, the formula predicted 1 row where
   there were 2. Publish the row count directly as a `users_in_table`
   KPI instead of asking consumers to derive it, and document that the
   arithmetic is wrong. Regression test pins the four-way case and
   asserts the old identity genuinely fails on it.

2. `service_account_source` put a string in the numeric `metric_value`
   column, which makes a Glue crawler type the whole column as `string`
   and forces a cast on every SUM/AVG in QuickSight. Add a `metric_text`
   column for label-unit payloads; `metric_value` is now strictly
   numeric or empty.

3. `--chargeback-report` opened the snapshot with the platform default
   encoding, so a file with non-ASCII usernames failed to load under
   LANG=C -- the same bug already fixed on the CSV write path.

4. Docs claimed `--chargeback-report` avoided the API entirely. It skips
   the chargeback request only; /api/admin/service-accounts is still
   queried (verified by instrumenting the call). Corrected both READMEs
   rather than dropping the call, which is what makes is_service_account
   authoritative.

Samples and zip regenerated against the new schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
baz-reviewer[bot]
baz-reviewer Bot previously approved these changes Sep 4, 2026
Python's default float repr switches to exponent form outside roughly
1e-5 .. 1e16, so a workspace with a very small data_mb rendered as
"1e-05". Athena's CSV SerDe does not parse that as a double -- the value
silently becomes NULL in the dashboard, which is the worst failure shape
for this pipeline: no error, just a wrong number.

The low bound is reachable in practice (a near-empty workspace logging a
few bytes), and the test cluster has 500 empty workspaces.

Format floats explicitly with %f, trimming padding zeros, and map
nan/inf to an empty field rather than emitting text that would force the
whole column to type as string. Ints pass through untouched -- arbitrary
precision, never exponential. Real chargeback magnitudes (20545.68,
88834.25, 71200.75) still round-trip exactly.

Tests updated to compare rendered values numerically rather than by
Python type, since floats are now decimal strings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@baz-reviewer
baz-reviewer Bot dismissed their stale review September 4, 2026 15:32

Baz dismissed its prior approval because a re-review found new findings.

Comment thread cometx/cli/admin_growth_csv.py Outdated
The previous fix used "%.6f", which avoided scientific notation but
quantized ordinary values: 0.1234567 became 0.123457. That traded a
silent NULL in Athena for a silently wrong number, which is worse. Five
of six sample values lost data.

Use repr() whenever it does not produce an exponent -- it is the
shortest string that round-trips to the identical float -- and fall back
to exact positional expansion via Decimal only when repr goes
exponential. Decimal(float) is lossless, so nothing is rounded.

Every tested value now round-trips exactly AND contains no exponent,
including the real chargeback magnitudes and the pathological ends
(1e-07, 1e20, 1/3, 0.1+0.2).

Reported by Baz review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@dsblank dsblank left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed the full diff on feat/growth-report-csv-export and verified each finding against the branch.

The design decisions in the description hold up — user-grain fact tables, long-format KPIs, and building the CSV from parsed records rather than report_data are all the right calls. Six issues below; (1)–(3) are straightforward, (4)–(5) are a policy call.


1. Workspace numerics bypass the float-formatting guard — cometx/cli/admin_growth_csv.py:174-176

build_workspaces_rows emits w.num_experiments and w.data_mb raw, while the users table (:160-162) and org KPIs (:275) route through _num_or_empty. A workspace with totalSizeInMb = 4e-06 writes 4e-06, which Athena's CSV SerDe reads as NULL; a NaN/inf from the API would be emitted as literal text and force Glue to type the column as string. The module docstring calls out exactly this case ("a near-empty workspace can report a tiny data_mb").

The existing test at tests/unit/test_admin_growth_csv.py:198 asserts float(rows[0][5]) == 12422.75, which passes on a raw float, so it can't catch this.

2. The NotFound.__str__ fix misses the path it was written for — cometx/cli/admin_growth_report.py:215

_short_api_error opens with text = " ".join(str(exc).split()). The chargeback fetch in build() catches its exception and calls this, never reaching admin.py's _exception_text. So for the exact scenario the PR describes — a 404 whose body is an ingress HTML page — str(exc) raises TypeError: __str__ returned non-string from inside the except block, and the real HTTP error is still replaced by a traceback from the handler. _short_api_error should use the same tolerant rendering.

3. Two handlers still use bare str(e) — cometx/cli/admin.py:917 and :923

Same hazard, worse consequence: a broken __str__ raises inside the except, so the sys.exit(1) never runs and the resulting TypeError falls through to the outer handler at :935, which prints and returns 0 — losing both the message and the non-zero exit this PR sets out to guarantee. Both should use _exception_text.

4. Parse failure writes empty CSVs and exits 0 — cometx/cli/admin_growth_report.py:1187-1194

When parse_users/parse_workspaces throws, the code prints a warning and sets people_users = ws_records = []. With --csv-dir that silently writes three header-only CSVs and exits 0 — precisely the "reports success to a scheduler while shipping nothing" mode this PR fixes for write failures. A parse failure should either abort the CSV export or exit non-zero.

5. Same shape for org KPIs — cometx/cli/admin_growth_report.py:1287

Any exception in adoption_stats / _window_growth / classify_accounts / collect_org_kpis leaves self._last_parsed at the earlier (users, ws, []), so growth_org_kpis.csv is written with a header and zero rows, exit 0. A monthly Glue load would quietly ingest an empty KPI partition with only a Warning: line in the log.

6. Dead README link — README-ADMIN.md:322

Links to docs/csv-samples/README.md, but this same PR adds docs/ to .gitignore at line 134 with the comment "never commit". The samples aren't in the repo, so the link is broken for every reader of the public README. Worth noting the blanket docs/ ignore will also silently swallow any future real docs directory here.

All six verified against the branch before changing anything; two were
reproduced as live defects.

1. build_workspaces_rows emitted raw floats while the users table and
   org KPIs both routed through _num_or_empty. A workspace with
   totalSizeInMb=4e-06 wrote "4e-06" (NULL to Athena) and a NaN/inf
   wrote literal text (forcing Glue to type the column as string).
   The existing test used float(...) == ..., which passes on a raw
   float, so it could not catch this.

2/3. Bare str(e) in three handlers. This is the serious one: a broken
   __str__ raises INSIDE the except block, so sys.exit(1) never runs,
   the TypeError falls through to the outer handler, and the process
   exits 0 -- defeating the exact non-zero-exit guarantee this PR
   added. Reproduced end-to-end. Every operator-facing render now goes
   through a single tolerant helper, moved to cometx.utils so
   _short_api_error can share it (admin.py imports FROM
   admin_growth_report, so the helper cannot live in admin.py).

4/5. A parse failure or KPI-collection failure degraded to empty lists
   and wrote three header-only CSVs, exit 0. In Glue that is
   indistinguishable from "this org has no users", and tells a monthly
   scheduler the run succeeded. The reporter now records why the export
   would be empty and generate_growth_report raises instead of
   publishing it. The HTML path keeps its degrade-and-continue
   behaviour, which is honest there because a human reads it.

6. README linked to docs/csv-samples/README.md, which this PR
   gitignores -- broken for every reader of the public README. Replaced
   with instructions to generate the samples. Also narrowed the ignore
   from all of docs/ to the two working directories, so a future real
   docs directory is not silently swallowed.

Each fix has a regression test confirmed red before the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LeoRoccoBreedt

Copy link
Copy Markdown
Contributor Author

Thanks @dsblank — all six addressed in 6ee1db3. I verified each against the branch before changing anything; two reproduced as live defects.

1. Workspace numerics bypassed the float guard — confirmed. 4e-06 and literal nan/inf went straight into the CSV. Now routed through _num_or_empty like the other two tables. You were right that the existing assertion (float(rows[0][5]) == 12422.75) passes on a raw float and could never have caught it — the new test asserts the rendered string has no exponent.

2 & 3. Bare str(e) — this was the serious one, and I'd missed that my own fix didn't cover the path it was written for. Reproduced end-to-end: a broken __str__ raises inside the except, sys.exit(1) never runs, the TypeError falls to the outer handler, and the process exits 0 — defeating the exact guarantee this PR adds.

Fixed by routing every operator-facing render through one tolerant helper. I moved it to cometx/utils.py rather than duplicating: admin.py imports from admin_growth_report.py, so _short_api_error couldn't reach a helper defined in admin.py.

4 & 5. Empty-CSV-and-exit-0 — agreed, and it's the same failure mode this PR set out to fix for write failures. GrowthReporter now records why an export would be empty, and generate_growth_report raises rather than publishing it. The HTML path keeps degrade-and-continue, which stays honest there because a human reads the output and can see the empty section; a Glue partition can't.

6. Dead README link — confirmed broken. Replaced with instructions to generate the samples via --csv-dir. Also took your second point: narrowed the ignore from all of docs/ to docs/superpowers/ and docs/csv-samples/, so a future real docs directory isn't silently swallowed.

Each fix has a regression test I confirmed red before the change — including reverting all four fixes at once to check none of the new tests were vacuous. 252 tests pass.

Comment thread cometx/cli/admin_growth_report.py Outdated
Comment thread cometx/cli/admin_growth_report.py
LeoRoccoBreedt and others added 2 commits September 8, 2026 20:55
Two further Baz findings, both reproduced before changing anything.

1. A --chargeback-report file containing JSON `null` silently fell back
   to a live API call. `build()` treats `chargeback is None` as the
   fetch-it-live sentinel, and json.load("null") produces exactly that,
   so a bad local file was reported as "the chargeback endpoint is
   unavailable" -- blaming the server for a client-side problem. Any
   non-object payload is now rejected with a message naming the file.

2. A workspace-scoped export was byte-shaped identically to an org-wide
   one: same filenames, same headers, only fewer rows and a lower
   total_workspaces. Loaded into the same Glue partition it reads as an
   organization that shrank overnight, and a scoped run would silently
   replace an org-wide snapshot. Adds a `scope` label metric
   (`organization`, or `workspaces:a,b`) so the data says which it is.

Rather than reject scoped exports, the capability is kept and made
self-describing -- scoping is legitimate, being indistinguishable is
not.

Note the first regression test I wrote for (1) passed against the
unfixed code: the CLI's broad `except Exception` swallowed the
AssertionError and still exited 1, so it could not tell a rejected
snapshot from a live fetch that merely failed. It now records the call
and asserts no fetch was attempted. All three new tests confirmed red
before their fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit ran `black cometx tests` rather than the files this
branch touches, so eleven modules unrelated to the CSV export picked up
pure formatting churn and landed on the PR: admin_gpu_app,
admin_gpu_report, config, copy, delete_assets, log, rename_duplicates,
smoke_test, upload_optimizer_experiments, download_manager and magics.

None of it is wrong, but it is noise in a review and would rewrite
history other branches are working against. Restored to origin/main.

The PR is back to the 8 files it should touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread cometx/cli/admin.py
Comment thread cometx/cli/admin_growth_csv.py Outdated
Two more Baz findings, both consequences of my own previous fixes and
both reproduced before changing anything.

1. `{}` passed the isinstance(dict) snapshot check added last commit.
   The chargeback parsers are deliberately permissive -- they return
   empty lists rather than raising -- so `_export_blocked` never fired
   and the run published three "successful" zero-row CSVs and exited 0.
   That is precisely the false-success mode the export guard exists to
   prevent, reintroduced through a narrower door. A snapshot must now
   carry at least one of `users` or `workspaces`. A one-section snapshot
   is still accepted: degraded but real, and rejecting it would break
   legitimate input (covered by its own test).

2. The `scope` label serialized the raw CLI request, so it could name a
   workspace that does not exist or was dropped by --exclude-personal.
   `--workspace team-a ghost` advertised `workspaces:ghost,team-a` while
   only team-a had rows, sending a dashboard that filters on `ghost` to
   an empty result. `scope` is now derived from the surviving records,
   and `scope_requested` records the ask separately when the two differ
   -- so a filter that did not land is visible rather than silent.

Both regression tests confirmed red before their fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread cometx/cli/admin.py Outdated

@dsblank dsblank left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed at ca02f95. All six findings from my previous review are fixed, each with a targeted regression test. Full unit suite passes locally: 258 passed (was 240).

Verified fixes:

# Finding Fix
1 Workspace numerics bypassed _num_or_empty admin_growth_csv.py:181-183 routes all three through it; test_workspace_numerics_go_through_the_float_guard asserts the string form rather than float(...) ==, so it can actually fail
2+3 Bare str(e) in three handlers Helper moved to cometx/utils.py:405 as exception_text, used by _short_api_error and both admin.py handlers. Putting it in utils is the right call given admin.py imports from admin_growth_report
4+5 Parse / KPI failure wrote header-only CSVs, exit 0 _export_blocked reason string; generate_growth_report raises instead of publishing. HTML keeps degrade-and-continue, which is correct there
6 Dead README link Replaced with generate-your-own instructions; .gitignore narrowed from all of docs/ to the two working directories

The self-caught findings in 216107f and ca02f95 (JSON null snapshot silently falling back to a live fetch; scoped exports byte-identical to org-wide ones) are good catches, and reverting the stray black sweep across eleven unrelated modules in 7f2f520 was the right call.

Five new items below. Only (A) needs to block.


A. The structural-empty guard covers the snapshot path but not the live one — cometx/utils.py:522, cometx/cli/admin.py:900

ca02f95 rejects a --chargeback-report file carrying neither users nor workspaces, on the reasoning that the parsers are permissive and would otherwise publish a zero-row export. fetch_chargeback_report ends in a bare return response.json(), so the live path gets no equivalent check. Reproduced against this branch with the fetch mocked to return {}:

export_blocked: None
last_parsed lens: [0, 0, 7]

Two header-only tables plus a KPI table of zeros, exit 0 — the exact false-success mode the new guard exists to prevent, reachable through the path that actually runs monthly in production. The check reads more naturally after the fetch in build(), where it covers both sources with one implementation.

B. A scoped run that matches nothing publishes an empty export — cometx/cli/admin_growth_csv.py:277

--workspace ghost against a payload containing only team-a:

blocked: None      users/ws: 0 0
KPI: ('scope', '', 'label', 'workspaces:')
KPI: ('scope_requested', '', 'label', 'workspaces:ghost')

scope_requested makes the miss visible in the data, which is the mitigation the commit intends, but the run still exits 0 having published an empty partition. Separately, scope serializes as the bare string workspaces: with an empty list, which every dashboard filter then has to special-case. An empty effective set looks like a natural _export_blocked trigger.

C. _export_blocked is never reset per build — cometx/cli/admin_growth_report.py:413

Set in __init__ and at :1224/:1328, but not cleared at the top of build(). A reused reporter whose first build failed would block every subsequent export. The CLI builds once, so this is latent rather than live — worth a one-line reset for the invariant.

D. Two handlers in the hardened function still render bare {exc} — cometx/cli/admin_growth_report.py:1260 and :1283

The KPI handler eight lines below was converted to _short_api_error, and exception_text's own docstring says "every caller that renders an exception for the operator must go through here." I checked the live risk before raising it: _fetch_service_accounts swallows all exceptions internally and returns None, so an API-borne broken-__str__ cannot actually reach :1283. Consistency, not a defect.

E. Nit — generate_growth_report:354-359 calls reporter.export_blocked() twice; assign it to a local once.

Adjacent and out of scope for this PR: admin.py:663, :784 and :854 still use bare str(e) in other subcommands' handlers — same hazard class, reasonable as follow-up.


Requesting changes on (A), since it is the same class of bug as the fix that motivated the guard, on the more commonly exercised path. (B) is a short addition to the same check. (C)–(E) can ride along or wait.

Doug's re-review. (A) blocks; implementing (B)-(E) alongside since they
are all in the same function.

A. The structural-empty guard I added in ca02f95 covered only the
   --chargeback-report path. `fetch_chargeback_report` ends in a bare
   `return response.json()`, so a live `{}` response reached the
   permissive parsers, left _export_blocked unset, and published two
   header-only tables plus a zeros KPI table, exit 0. That is the same
   false-success mode the guard exists to prevent, on the path that
   actually runs monthly in production -- I had hardened the occasional
   path and left the common one open. Reproduced before fixing.

   Moved the check into build() after the fetch, per Doug's suggestion,
   so one implementation covers both sources.

B. `--workspace ghost` against a payload without it exported an empty
   partition at exit 0, and serialized `scope` as the bare string
   `workspaces:` that every dashboard filter would have to special-case.
   An empty effective scope now blocks. Checked after assembly, since
   only then is it known which workspaces survived scoping and
   --exclude-personal. A scope that does match still exports (tested).

C. _export_blocked now resets at the top of build(); a reused reporter
   whose first build failed would otherwise block every later export.
   Latent, not live -- the CLI builds once -- but the invariant is
   cheap.

D. Routed the leaderboards and personal-vs-service warnings through
   _short_api_error, and the "could not reach the chargeback endpoint"
   error through exception_text. Doug correctly notes the API-borne case
   cannot reach the first two today, but the endpoint error is exactly
   the path this hardening was written for.

E. Assign reporter.export_blocked() to a local instead of calling twice.

Regression tests for A, B and C confirmed red before their fix; the
positive cases (matching scope, org-wide) are tested too, so the new
blocks cannot fire on a legitimate run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LeoRoccoBreedt

Copy link
Copy Markdown
Contributor Author

Thanks Doug — all five implemented in 9276ed3. I reproduced (A) and (B) against the branch before changing anything; both were exactly as described.

A — blocking finding. You're right, and the framing matters: I hardened the --chargeback-report path and left the live one open, which is the path that actually runs monthly. A {} response reached the permissive parsers, left _export_blocked unset, and published two header-only tables plus a zeros KPI table at exit 0.

Took your suggestion and moved the check into build() after the fetch, so one implementation covers both sources rather than two that can drift.

B. Also confirmed, including the workspaces: label detail. An empty effective scope now blocks. It has to be checked after assembly, since only then is it known which workspaces survived scoping and --exclude-personal. Added a positive test (--workspace team-a where it matches, and an org-wide run) so the new block cannot fire on a legitimate run.

C. Added the reset at the top of build().

D. Routed the leaderboards and personal-vs-service warnings through _short_api_error. I also converted the "could not reach the chargeback endpoint" error at what is now :443 — you didn't flag it, but it renders a bare {exc} and is precisely the 404-with-HTML-body path this hardening was written for, so it seemed the most load-bearing of the three. The --personal-pattern warning at :538 renders an re.error, not an API exception, so I left it.

E. Done.

Regression tests for A, B and C confirmed red before their fixes. 262 tests pass.

On the adjacent items — admin.py:663, :784, :854 in the other subcommands' handlers: agreed they're the same hazard class, and I've left them out of this PR as you suggested. Happy to take them as a follow-up.

`bool` subclasses `int`, so `_num_or_empty(True)` passed the numeric
path and wrote the literal `True` into experiment_count / data_logged_mb
/ opik_span_count. A Glue crawler then types the whole column as
`string` and every aggregation in QuickSight needs a cast -- the same
failure class as the scientific-notation and metric_value bugs earlier
in this branch.

Booleans now render as an empty field. Coercing to 1/0 would be worse:
it invents a count the source never reported, and "no usable value" is
what an empty field already means everywhere else in this export.

Requires a malformed upstream payload, so this is defensive rather than
a live defect. The genuine boolean columns (is_suspended,
is_service_account) are unaffected -- they emit 0/1 through explicit
conditionals, never through _num_or_empty -- and the test asserts that
so a future change here cannot silently break them.

Reported by Baz review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread cometx/cli/admin_growth_report.py Outdated
Comment thread cometx/cli/admin_growth_csv.py Outdated
Two cosmetic findings from Baz; no behaviour change.

`export_blocked()` / `_export_blocked` read as booleans but store and
return an explanatory string, so `if reporter.export_blocked():` looked
like a flag check while the value was a sentence. Renamed to
`export_block_reason` / `_export_block_reason`, including the local at
the call site, which had the same problem.

Also reworded the boolean-guard docstring: "rejected to empty" ->
"converted to empty fields".

263 tests pass; the rename is mechanical and covered by the existing
export-block tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread cometx/cli/admin_growth_report.py Outdated
Running the CLI locally leaves growth_report.html, the three
growth_*.csv files and comet-chargeback-report.json in the working
tree. These hold real usernames, emails and usage figures from whatever
deployment they were generated against -- the ones sitting here now
carry live @comet.com addresses from the EKS test run -- and this is a
public repo.

Nothing was committed, but they were untracked-and-unignored, so a
`git add -A` would have swept them in.

Matched by filename rather than directory so they are caught wherever
--csv-dir points, not just ./out. The synthetic samples in
docs/csv-samples/ are unaffected: that directory is already ignored, and
the generator still reproduces them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread .gitignore Outdated
@dsblank

dsblank commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Review is in. It confirms 8 findings; I spot-checked the four highest-severity ones against the code directly.

Correctness

  1. --exclude-personal runs still label themselves organization — admin_growth_csv.py:283
    The scope KPI only branches on --workspace. With --exclude-personal --personal-pattern '^user-' on a 2-workspace payload, the export writes total_workspaces,1 alongside scope,,label,organization — provenance byte-identical to a genuine org-wide run, so a scoped-down snapshot silently overwrites the org-wide Glue partition. This defeats the exact purpose the scope metric was added for. The exclude-personal state needs to reach the label.

  2. The emptiness guard is an OR-of-presence, not an AND — admin_growth_report.py:458
    not chargeback.get("users") and not chargeback.get("workspaces") fires only when both sections are missing. Given {"workspaces": [...]}, all three files get written, growth_users.csv is header-only, the total_users/active_users_/new_users_ KPIs vanish entirely, and it exits 0 — the "indistinguishable from an org with no users" case the block-reason machinery exists to prevent. Duplicate check at admin.py:897 has the same shape. Blocking when either section is absent matches the stated intent.

  3. Non-dict chargeback sets the block reason, then crashes anyway — admin_growth_report.py:466
    The guard assigns _export_block_reason but execution falls straight into _filter_personal_chargeback. build(None, chargeback=[1,2,3], exclude_personal=True) raises AttributeError: 'list' object has no attribute 'get' instead of the message you wrote. _scope_chargeback (line 267) has the same unguarded .get and is hit by any --workspace run. Only the live-fetch path is exposed — which is the path that runs monthly. Should raise GrowthReportError or return early.

  4. _num_or_empty falls through for strings and containers — admin_growth_csv.py:120
    _num_or_empty("12abc") → '12abc'; a dict passes through and csv writes {'a': 1}. parse_workspaces hands w.get("numberOfExperiments") or 0 straight in, so a chargeback field arriving as a JSON string writes text into num_experiments and makes the Glue crawler type the column string — precisely the failure the docstring claims to guard. A closing isinstance(value, (int, float)) check fixes it.

Robustness / operability

  1. Partial CSV write leaves a mixed-report_date directory — admin_growth_csv.py:369 — three sequential writes, no staging. If write 2 or 3 fails, growth_users.csv has today's rows while the others hold the last run's. Exit is 1, but a directory-syncing upload step ships an inconsistent partition. Temp names + rename after all three succeed makes it atomic.

  2. --csv-dir "" gives a bare FileNotFoundError: '' — admin_growth_csv.py:349 — os.path.exists("") is False so the NotADirectoryError guard is skipped and os.makedirs("") throws, with no mention of the flag.

  3. _last_service_account_names isn't reset in build() — admin_growth_report.py:415 — unlike _export_block_reason and _last_parsed. A reused reporter whose second build has an empty user roster stamps is_service_account from the first build's set. Latent (the CLI builds once), but the sibling fields were reset for this reason.

  4. active_users_%dd embeds the flag value — admin_growth_csv.py:240 — switching --active-window 60d → 30d renames the metric, so a QuickSight series filtered on active_users_60d goes flat instead of showing a definition change. A stable active_users plus an active_window_days metric keeps it continuous.

Verified clean: row/header arity on all three tables, Decimal expansion both exponent directions and -0.0, NaN/inf, bool rejection, exception_text fallback, sys.exit(1) not swallowed by the except Exception handlers, collect_org_kpis arg order, scope/scope_requested mismatch, keyword-only defaults preserving existing callers. 105 related tests pass.

Findings 1–3 look like the ones worth fixing before merge — all three undermine guards this PR added deliberately.

 missing section

Doug's findings 1-3.

1. The scope metric only branched on --workspace, so a run using the
   pre-existing --exclude-personal dropped workspaces and still labelled
   itself `organization` -- provenance byte-identical to a genuine
   org-wide run, which would overwrite the org-wide Glue partition. That
   is the exact failure the metric was added to prevent; it just did not
   know about the other way the data gets filtered. Now labels
   `organization_excluding_personal` and emits an
   `excluded_personal_workspaces` count. Derived from how many
   workspaces were actually dropped, not from the flags, so a pattern
   that matches nothing still reads `organization` (tested).

2. The emptiness guard read `not users and not workspaces`, firing only
   when BOTH were absent, while its message said "neither". A
   workspaces-only payload exported a header-only users table with the
   total_users / active_users_* / new_users_* KPIs silently missing.
   Either section missing now blocks. The test asserting the old
   behaviour was mine and was wrong -- inverted.

   Also removed the duplicate check in admin.py: build() applies the
   same rule to both the snapshot and the live fetch, and having two
   implementations is what let them disagree.

3. Declined, and removed the guard behind it instead. Reaching that
   AttributeError needs our own admin endpoint to return a JSON array;
   the response shape is a contract we own, not untrusted input. The
   `isinstance(chargeback, dict)` half was over-defense from an earlier
   round -- dropping it makes the finding moot and removes a branch that
   cannot execute in production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LeoRoccoBreedt

Copy link
Copy Markdown
Contributor Author

Thanks Doug — 1 and 2 fixed in 66c0e7a, 3 handled by removing the guard rather than extending it. Reproduced all three first.

1 — fixed. Confirmed: --exclude-personal --personal-pattern '^user-' on a 2-workspace payload exported 1 workspace under scope,,label,organization. You're right that this defeats the metric's whole purpose — it only knew about --workspace and missed the other, pre-existing way the data gets filtered.

Now emits organization_excluding_personal plus an excluded_personal_workspaces count. Derived from how many workspaces were actually dropped rather than from the flag being set, so --exclude-personal with a pattern matching nothing still reads organization — tested both ways, since a label that cries wolf is its own problem.

2 — fixed. My bug: I wrote not users and not workspaces while the message said "neither". Either section missing now blocks.

I also deleted the duplicate check in admin.py rather than fixing it twice. build() applies the same rule to both the snapshot and the live fetch, and having two implementations is precisely what let them drift. One of my own tests asserted the old accept-a-workspaces-only-payload behaviour; it was wrong for the reason you give, and I inverted it.

3 — declining, and removing the guard behind it. Reaching that AttributeError requires /api/admin/chargeback/report to return a JSON array. We own that endpoint; its response shape is a contract, not untrusted input. Rather than add a raise, I dropped the isinstance(chargeback, dict) half of the check — it was over-defense I added chasing an earlier finding, and removing it makes the finding moot while deleting a branch that cannot execute in production.

Same reasoning for the _scope_chargeback .get you note: it is only reachable from a payload our API cannot emit.

On 4–8: all fair, and I'd like to take them as follow-ups rather than another round here. 4 is the closest to earning its place; 5–8 are robustness and operability polish on a path that has now had eleven review rounds without a real Glue load behind it. This PR started as "a flag that writes the CSVs" and the feature itself is ~390 lines; most of what has accumulated since is hardening against shapes our own API does not produce. I'd rather ship, run a real monthly export into Glue, and let that surface what actually matters.

265 tests pass, 8 files. Happy to do any of 4–8 now if you'd rather they land together.

Comment thread cometx/cli/admin_growth_csv.py Outdated
LeoRoccoBreedt and others added 2 commits September 10, 2026 17:22
The filter trimmed only the workspace list and left the user roster
whole, so a single `scope` label covered two different populations: on
a 2-workspace payload the workspace table reported 5 experiments while
the user-derived KPIs reported 20, and users belonging solely to an
excluded workspace still appeared in growth_users.csv.

Labelling the user table "roster-wide" was the alternative, but that
leaves a dashboard summing incomparable numbers under one partition.
Narrowing is also what --workspace already does.

Reuses `_scope_chargeback`, the helper that implements exactly this
rule for an explicit --workspace selection, rather than growing a
second implementation that could drift. The two filters now produce
identical output for an equivalent selection, which is asserted
directly.

Reported by Baz review. Regression tests confirmed red beforehand;
the unfiltered path is tested too, so the narrowing cannot fire when no
filter is active.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The leading slash anchored the pattern to the repository root, so an
HTML report written elsewhere via --output stayed unignored. These
files carry real usernames, emails and usage figures, and this is a
public repo, so the pattern should match at any depth -- as the three
growth_*.csv patterns beside it already do.

Reported by Baz review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread cometx/cli/admin_growth_report.py
Comment thread cometx/cli/admin_growth_report.py
Douglas Blank and others added 2 commits September 10, 2026 08:46
…nal runs

Baz findings 18 and 19, both confirmed against HEAD, plus the gaps a
reader hits next.

1. The post-assembly emptiness guard only ran when an explicit --workspace
   scope was given, so an --exclude-personal pattern matching every
   workspace wrote header-only CSVs and exited 0 -- exactly the "an org
   with no users" partition the guard exists to prevent, reached by the
   other filter. Both filters are now checked.

   Users are checked alongside workspaces, for the same reason the
   structural guard blocks on EITHER missing section: both filters narrow
   the roster to members of the surviving workspaces, so surviving
   workspaces with no members still ship a header-only growth_users.csv
   and drop every user-derived KPI.

   The HTML-only degradation path is preserved and tested: an empty
   section is honest in HTML, it is only the CSV partition that must not
   ship. Passing --csv-dir is what turns an empty result into an error.

2. _scope_label only considered an explicit scope, so an unscoped
   --exclude-personal run labelled its HTML `Org-wide` while the CSV
   called the same run `organization_excluding_personal`. The filter
   narrows users as well as workspaces, so the report is a subset, not
   the org -- and the header's own workspace/user counts were already
   post-exclusion. Driven by the count actually dropped, not by the flag,
   matching how collect_org_kpis decides the same thing; a test pins both
   provenances to one run so they cannot drift apart again.

Found while verifying the above:

3. write_growth_csvs wrote the three tables in place, one after another,
   so a failure partway through (a full disk, a read-only file from a
   previous run) left one fresh file beside two stale ones. The upload
   step syncs the directory, not the path list returned, and nothing
   about the files says the run failed -- worse than the header-only
   export, which at least fails loudly. They are staged and moved into
   place only once all three are written.

4. build_org_kpi_rows wrote metric_value raw while the users and
   workspaces builders guard their numeric columns. It is the single
   writer of that column, so the guard belongs there; idempotent, and a
   no-op on the report's own path.

5. README documented neither the `organization_excluding_personal` scope
   nor `excluded_personal_workspaces` (added last commit), and still
   promised header-only files for an empty run -- which the block reasons
   have contradicted since they were introduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit hardened growth-report's exit code, but `admin()` had
14 more `print("ERROR: ..."); return` paths -- across usage-report,
gpu-report and optimizer-report -- plus an outer handler that caught
everything else and returned 0. All of them reported success while
producing nothing.

That is the worst shape for the unattended runs this command is built
for: a monthly cron or CI job sees exit 0, records the run as done, and
the pipeline downstream silently keeps whatever stale data it had. It is
the same failure the CSV write-failure fix addressed, left in place
everywhere except the one path that happened to get reviewed.

- All 14 error returns now `sys.exit(1)`. Each one already printed
  `ERROR:` first; none were legitimate non-error early returns.
- The outer `except Exception` exits 1 rather than returning.
- CONTROL+C exits 130 (128 + SIGINT, the shell convention) rather than 0.
  An interrupted run is not a successful one.
- Those handlers now render through `exception_text` like the
  growth-report path does. With a `sys.exit(1)` after the print, a broken
  `__str__` (comet_ml's `NotFound` returns None for a non-JSON 404 body)
  would raise inside the handler and skip the exit entirely -- restoring
  the exact bug being fixed. Tested.

`--debug` still re-raises for a full traceback, and the interpreter exits
non-zero on the propagated exception, so that path was already correct.

New tests pin the exit code for each subcommand's failure paths; 9 of the
10 fail against the previous commit. The tenth pins a healthy run to 0,
so the guard cannot overshoot into failing a successful run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread cometx/cli/admin_growth_report.py Outdated
Comment thread cometx/cli/admin_growth_csv.py Outdated
Comment thread cometx/cli/admin_growth_csv.py Outdated
Baz findings on the staging I added in 7e42a95.

1. Predictable `<name>.tmp` staged through `open(..., "w")` (high).
   `--csv-dir` is an operator-supplied path that may be world-writable, so
   a pre-planted symlink at the fixed name would be followed and truncate
   whatever it pointed at, and two runs into one directory would write and
   clean up each other's staged data under the same name. Staging now goes
   through `tempfile.mkstemp`, which is O_CREAT|O_EXCL and unpredictable.

   mkstemp creates 0600; these are published data files whose upload step
   may run as another user, so the mode is restored to what a plain
   `open()` would have produced rather than silently narrowed.

2. Mixed generations across the commit (high). Partially addressed, and
   the overclaim removed.

   Fixed: a failure partway through no longer leaves earlier replacements
   standing. Each displaced file is held aside until all three have
   landed, and any failure rolls the directory back to the generation it
   held on entry.

   Not fixed: POSIX has no atomic multi-file rename, so a reader walking
   the directory *during* the commit can still observe a mix. Closing that
   means publishing into a versioned directory and swapping a symlink,
   which changes the output layout consumers already read -- a spec change,
   not a bug fix, and one for the customer to weigh. The window is the
   microseconds between three renames, against a consumer that uploads
   after the process exits and checks the exit code; before this PR the
   same mix was visible for the entire duration of the write. The comment
   claiming a reader "sees either the whole previous set or the whole new
   one" was wrong and is now an explicit statement of the limit.

3. `excluded_personal` read as a boolean while holding a count. Renamed to
   `excluded_personal_count` throughout, and the label fragment built from
   it is now `excluded_label` so the two are not confusable.

Tests: a planted symlink at the old predictable name is not followed;
concurrent staging gets distinct names; a failed commit restores all three
previous files; published files keep the mode a plain open() would give.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread cometx/cli/admin_growth_csv.py Outdated
Comment thread cometx/cli/admin_growth_csv.py Outdated
Comment thread cometx/cli/admin_growth_csv.py Outdated
Net -21 lines in admin_growth_csv.py.

Baz found a real bug in the rollback machinery: if the inner restore
`os.replace(backup, path)` failed, the backup was never recorded in
`committed`, so cleanup could not remove it AND the destination was left
missing. Same gap when the initial displace failed, leaving an untracked
empty .bak.

Rather than add more bookkeeping, the bookkeeping is gone. That machinery
was speculative hardening I added in 7e42a95 -- not a review finding, not
a customer requirement -- and it has produced a genuine bug in each round
since. It guarded a mid-commit `os.replace` failure: an I/O error within
one directory, after every byte is already written, on a run that exits
non-zero either way. The cost of covering it exceeded the failure.

Kept, because these were real and are cheap:

- `mkstemp` staging. O_CREAT|O_EXCL, unpredictable name: a pre-planted
  symlink at a fixed `<name>.tmp` is never opened, and concurrent runs
  into one directory no longer share staged names. The realistic failure
  -- a write dying partway -- still publishes nothing at all.
- The mode restore now uses `os.fchmod` on the descriptor rather than
  `os.chmod` on the path, so there is no window between creating the file
  and setting its mode in which the name could be pointed elsewhere. This
  is the actionable half of the "attacker controls chmod" finding; the
  publish itself is `rename()`, which takes a pathname and has no
  by-descriptor form, and an attacker who can write to `--csv-dir` can
  rewrite the published CSV a millisecond later regardless.
- A chmod failure now warns instead of passing silently. It stays
  non-fatal (a filesystem without POSIX modes is no reason to fail a good
  export), but a file left at 0600 that an uploader cannot read is no
  longer invisible.

The comment and README now state the limit rather than implying a
guarantee: the commit is not a transaction, a reader mid-commit can see a
mixed set, and the exit code is the signal to trust. A test pins that
limit so nobody re-derives the guarantee from the code's shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread cometx/cli/admin_growth_csv.py Outdated
The mode was widened right after `mkstemp` and before any rows were
written, so a staged file holding chargeback data was group/world
readable for the whole duration of the write, and a `.tmp` left by a
killed run stayed readable. Widening now happens after the rows are
written, with an explicit flush first so nothing is still sitting in
Python's buffer when the file becomes readable.

Not applied to the published path, which is what the finding suggested:
`os.chmod` by name after `os.replace` would reintroduce the exact window
removed in 6f937c5, where the name can be pointed elsewhere between
publishing and setting the mode. The mode survives the rename, so setting
it on the descriptor beforehand is equivalent and has no such window.

Finished-but-unpublished temporaries do sit at the published mode while
later tables are staged. That is deliberate: they hold complete tables,
identical in content and mode to the files they are about to become.

Test observes the mode of the descriptor actually being written -- not a
directory scan, which would also catch those completed temps -- and fails
against the previous ordering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dsblank
dsblank merged commit f1f95c9 into main Sep 10, 2026
2 checks passed
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