feat: add scheduled usage metrics report job for UX team (OPS-4148) - #5960
feat: add scheduled usage metrics report job for UX team (OPS-4148)#5960weimiao67 wants to merge 24 commits into
Conversation
Add storage account URL, container name, report prefix, and reporting lookback-window (days) properties to the DataToolsConfig protocol and all five concrete configs. Azure reads them from env vars (URL hard-fails if unset, matching the existing secret-config pattern); local/dev/pytest return None for the URL so the job writes a local file instead of uploading. Part of #4148 (OPS usage metrics for UXers). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add upload_blob() to azure_utils, mirroring the managed-identity/RBAC path used by the read helpers: it builds a DefaultAzureCredential-backed BlobServiceClient inside a context manager and uploads with overwrite=True. Requires the identity to have write access (Storage Blob Data Contributor) on the target container. Part of #4148 (OPS usage metrics for UXers). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the usage_metrics job module: aggregate ops_event into per-day x division x role count buckets and deliver a CSV to Blob storage (dated file plus a stable latest.csv) or a local file. Counting is scoped and defensive: - only SUCCESS events are counted, so failed/aborted requests do not inflate metrics or deactivation counts - a configurable lookback window bounds the scan to a reporting period rather than re-reading the whole append-only audit log - day bucketing compares naive created_on against a naive-UTC cutoff - login actor is read from event_details (created_by is NULL on login rows); unresolved actors bucket under UNKNOWN - deactivations are derived from UPDATE_USER status-change payloads using the UserStatus enum names Part of #4148 (OPS usage metrics for UXers). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the usage_metrics.sh entrypoint wrapper and a create_usage_metrics_job.sh provisioning script. The job runs weekly (cron "50 4 * * 1" == 04:50 UTC Monday, i.e. late Sunday night US Central) so the UX team sees fresh results Monday morning. Deploy to staging first, gather feedback, then rerun against production env vars/managed identity. Part of #4148 (OPS usage metrics for UXers). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cover the attribution helpers (login actor extraction, deactivation detection via UserStatus enum names, lookback parsing), CSV shape, and loaded_db integration for real aggregation: per-day x division x role counts, exclusion of FAILED events and out-of-window rows, and UTC day-boundary bucketing. Seeded event timestamps are now-relative so the reporting window includes them on any run date. Part of #4148 (OPS usage metrics for UXers). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deliver a two-sheet .xlsx alongside the existing usage-metrics CSV: an "Aggregate" sheet mirroring the CSV counts, plus a "Per-user" sheet listing each named user who signed in during the reporting window (name, email, division, roles, sign_in_count, last_sign_in_utc). Per-user sign-ins are aggregated on the resolved actor id only (not fanned out per role), sourced from SUCCESS LOGIN_ATTEMPT events over the same lookback window as the aggregate view, with a fallback to the event_details snapshot for users no longer in the live table. upload_blob gains an optional content_type so the .xlsx carries the spreadsheet MIME type. Adds openpyxl (written directly, no pandas) and relocks. Aggregation runs once per run and feeds both renderers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Pipfile.lock was regenerated on Apple Silicon (arm64), where SQLAlchemy's platform-gated greenlet dependency evaluates as not required and was dropped from the lock. Snyk resolves greenlet as required from the Pipfile and failed the Data Tools SCA scan with SNYK-OS-PYTHON-0013 (Missing required packages, 422). Relocked on linux/amd64 to restore greenlet while keeping the intended openpyxl addition. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s-for-uxers # Conflicts: # backend/data_tools/Pipfile.lock
The two-sheet .xlsx already carries an "Aggregate" sheet with the same per-day x division x role counts as the CSV, so the CSV was redundant. Remove build_csv/generate_report_csv and CSV upload; run_usage_metrics now uploads only the workbook (dated + latest) and returns its bytes. Also fix create_usage_metrics_job.sh --args, which passed the command as one comma-quoted string that az stored as a single argument, causing the container to exit 128 (StartError) with no logs; pass separate tokens. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror the staging deploy's guarded usage-metrics-job image update in prod_be_build_and_deploy.yml so the scheduled job stays on the latest image once created (no-op until it exists). Document the verified prod values and one-time creation runbook in data_tools CLAUDE.md. No production resources are changed by this commit; actual job creation is a separate manual step after merge + prod deploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
josbell
left a comment
There was a problem hiding this comment.
Review findings
🔴 Critical — create_usage_metrics_job.sh:76: Bash # comment truncates the az command
The multi-line comment block (lines 76–81) is embedded inside the \-continued az containerapp job create invocation. In bash, when a \<newline> continuation is consumed, # on the next physical line starts a comment that ends at the physical EOL — and since line 76 has no trailing \, the continuation chain stops there. The az command is submitted after --cron-expression "50 4 * * 1" only, missing --args, --environment, --mi-user-assigned, --secrets, and --env-vars. Azure CLI requires --environment and will fail; with set -euo pipefail the script exits immediately and the job is never created. This means the script cannot currently be used to provision the job on any environment.
Fix: Move the comment block above the az containerapp job create call (before line 68), then remove lines 76–81 from inside the continued command.
🟠 High — utils.py:197: is_deactivating_update silently excludes all automated bulk deactivations
is_deactivating_update detects a deactivation by checking event_details.get("request.json") for a status field. This only matches manual UI-driven deactivations (where the HTTP request payload is captured under request.json). The automated batch deactivation path in disable_users.py creates UPDATE_USER events with event_details={"user_id": ..., "message": "User deactivated via automated process."} — no request.json key. details.get("request.json") returns None, and the function returns False for every automated deactivation. In a production environment where the weekly disable_users job is the primary deactivation mechanism, the deactivated_users metric will systematically undercount with no error or warning.
Fix: Also check for the automated deactivation format, e.g. look for the message key containing "deactivated", or check event_details.get("user_id") combined with absence of request.json.
🟡 Medium — utils.py:366: parse_lookback_days accepts 0, silently producing an empty report
parse_lookback_days validates that the configured value is a parseable integer but does not check > 0. With lookback_days=0, the cutoff is datetime.now() and OpsEvent.created_on >= cutoff matches no historical rows. The job succeeds and uploads a valid .xlsx with column headers but no data rows — no error or warning is raised. An operator who accidentally sets USAGE_METRICS_LOOKBACK_DAYS=0 would receive a blank report from the UX team with no indication the configuration was wrong.
Fix: Add if days <= 0: raise ValueError(f"usage_metrics_lookback_days must be > 0, got {days}") after the int() conversion in parse_lookback_days.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the thorough review — all three findings were valid and are now fixed in c94cc06. 🔴 #1 — bash comment truncating the 🟠 #2 — automated deactivations undercounted: Fixed at both ends. The 🟡 #3 — Added unit tests for the automated-format detection and the non-positive lookback rejection. |
|
@josbell can you please re-review so we can avoid stale PRs? Thank you! |
…s-for-uxers # Conflicts: # backend/data_tools/Pipfile.lock
What changed
Adds the first scheduled job in the repo: a weekly Azure Container App Job that aggregates existing
ops_eventactivity into a usage report and uploads it to Azure Blob Storage for the UX team. The report is delivered as a single two-sheet.xlsxeach run:name, email, division, roles, sign_in_count, last_sign_in_utc).backend/data_tools/src/usage_metrics/utils.py— aggregation module. Readsops_eventrows within a lookback window (SUCCESS-only), attributes each event to a division/role, and buckets counts.aggregate_user_sign_insbuilds the per-user view: sign-ins are counted on the resolved actor id only (not fanned out per role) from SUCCESSLOGIN_ATTEMPTevents over the same lookback window, falling back to theevent_detailssnapshot for users no longer in the live table.build_workbookrenders both sheets with openpyxl directly (no pandas). Uploads the workbook as dated + stable-latestblobs (two blobs), or writes it locally when no storage account is configured.backend/data_tools/src/azure_utils/utils.py— addsupload_blobhelper (managed identity / RBAC) mirroring the existing download path, with an optionalcontent_typeso the.xlsxis uploaded with the spreadsheet MIME type (clean browser download via SAS link).backend/data_tools/environment/*.py+types.py— addsusage_metrics_*config properties toDataToolsConfigand each environment (Azure reads env vars; local/dev/test use defaults).backend/data_tools/scripts/azure/create_usage_metrics_job.sh— one-time script to create the scheduled job. Configured to match how the real staging jobs run (public ghcr.io image, DB password as a container-app secret, shared user-assigned MI for Blob write). Validates required env vars up front so a missing password fails immediately rather than at cron time.backend/data_tools/scripts/usage_metrics.sh— wrapper the job executes..github/workflows/stg_be_build_and_deploy.yml— updates theusage-metrics-jobimage on each merge tomain(guarded so it's a no-op until the job exists).backend/data_tools/Pipfile/Pipfile.lock— addsopenpyxl(relocked).backend/data_tools/tests/usage_metrics/test_utils.py— unit tests for aggregation, attribution, per-user sign-in counting (incl. multi-role no-double-count, failed/out-of-window exclusion, deleted-user fallback), workbook shape, and xlsx-only upload.backend/data_tools/CLAUDE.md— "Enable on staging" runbook with verified staging values; documents the.xlsxoutput and the privacy/semantics notes.Verified on staging
The job was created and test-fired against staging (
opre-ops-stg-app-rg): it aggregated ~4,200ops_eventrows and uploaded the workbook todata/reports/. Two fixes came out of that verification:.xlsx"Aggregate" sheet already carries the same per-day × division × role counts the CSV did, so the CSV was redundant.build_csv/generate_report_csvand the CSV uploads are removed; each run now produces two blobs (usage-metrics-latest.xlsx+ dated) instead of four.create_usage_metrics_job.sh--argsfix. The script passed the command as one comma-quoted string ("/bin/ash, -c, ./…"), whichazstored as a single argument — the container exited 128 (StartError) with no logs. Now passed as separate tokens (/bin/sh+ script path), matching the working data-tools jobs.Issue
#4148
How to test
Run the unit tests:
cd backend/data_tools pipenv run pytest tests/usage_metrics/test_utils.py -vEnabling on staging (see
backend/data_tools/CLAUDE.md→ "Scheduled Usage Metrics Report"):Then confirm the report appears at
data/reports/usage-metrics-latest.xlsx(the two-sheet workbook with the Aggregate and Per-user sheets).A11y impact
Definition of Done Checklist