Skip to content

feat(analytics): PR-C -- Usage Analytics v1 API (/analytics/*) - #39

Merged
man4ish merged 1 commit into
mainfrom
feature/analytics-c-api
Aug 12, 2026
Merged

feat(analytics): PR-C -- Usage Analytics v1 API (/analytics/*)#39
man4ish merged 1 commit into
mainfrom
feature/analytics-c-api

Conversation

@man4ish

@man4ish man4ish commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Third of four sub-PRs implementing Usage Analytics v1 (see #37 PR-A, #38 PR-B). Wires the merged aggregator/clients/RBAC into 8 live endpoints.

Hard verification gate (user-mandated, completed before any PR-C code)

  • TES /api/runs: read routes_runs.py directly. It DOES carry organization_id/team_id per run, but is scoped to the forwarded token's own identity (store.list(organization_id=identity.organization_id, team_id=identity.team_id, filtered=True)), not by a query parameter. Consequence: workflows_run is always null for a platform_admin -- can't honestly answer "org X's workflows" for a token that may not belong to org X. Documented in service.py, not guessed.
  • Team-members API (GET /orgs/{org}/teams/{team}/members): read team_service.list_team_members directly -- a single unpaginated .all() query, always returns the complete roster. Confirmed safe for the roster-intersection mechanism -- implemented as planned.

What's in this PR

  • analytics/service.py -- orchestrates aggregator/prometheus/billing_client/tes_client per endpoint. Team-scoped queries/active-users use roster intersection (server-side only, never returns raw user IDs). A failed roster fetch returns null + team_scope_available: false, never silently substitutes org-level numbers.
  • analytics/router.py -- GET /analytics/{overview,queries,users,services,workflows,performance,usage} + /analytics/export (CSV), all behind Depends(require_analytics_scope), all cache-aside (5min TTL).
  • /analytics/performance explicitly labeled "scope": "platform" with org_id/team_id always null -- audit:events has no organization_id, per this feature's hard requirement these numbers are never presented as tenant-scoped.
  • Small additive extensions to aggregator.py/cache.py from PR-A (a services index, an internal-only raw active-user-id reader, an async cache helper).
  • main.py: mounted, same per-route (not blanket) authorization posture as routes_dashboard.py.

Test plan

  • python -m pytest: 1151 passed, 99.84% overall (gate: 98%). Entire analytics package (12 modules): 100% coverage.
  • RBAC-through-HTTP for the full platform_admin/org_admin/team_admin/regular-user matrix, date/org/team filtering, cache hit/miss, Prometheus/billing/TES-unavailable degradation, CSV export, raw-user-ID-never-exposed.

🤖 Generated with Claude Code

Third of four sub-PRs. Wires PR-A's aggregates and PR-B's clients/RBAC
into 8 live endpoints and mounts them on main.py.

Hard verification gate (user-mandated) completed before writing any of
this PR:
- Read TES's routes_runs.py directly: GET /api/runs DOES carry
  organization_id/team_id per run (RunRecord), but is scoped to the
  *forwarded token's own* identity (store.list(organization_id=identity.
  organization_id, team_id=identity.team_id, filtered=True)), not by a
  query parameter aimed at an arbitrary org. Consequence: workflows_run
  is always null for a platform_admin (can't honestly answer "org X's
  workflows" for an identity that may not belong to org X), documented
  in service.py's own module docstring rather than guessed.
- Read omnibioai-auth's GET /orgs/{org}/teams/{team}/members: unpaginated
  (team_service.list_team_members's own single .all() query), returns
  the complete roster in one call. Confirmed safe to use for team-level
  attribution via roster intersection -- implemented as planned.

Additions:
- analytics/service.py: orchestrates aggregator/prometheus/billing_client/
  tes_client into each endpoint's response. Team-scoped queries/active-
  users use roster intersection against analytics:user_activity/
  analytics:active_users (server-side only -- never returns raw user IDs).
  A failed roster fetch returns null fields + team_scope_available:false,
  never silently substitutes the org-level number.
- analytics/router.py: GET /analytics/{overview,queries,users,services,
  workflows,performance,usage} + /analytics/export (CSV), all behind
  Depends(require_analytics_scope), all cache-aside (5min TTL) via
  cache.get_or_set_async, all delegating computation to service.py.
- /analytics/performance is explicitly labeled "scope": "platform" with
  org_id/team_id always null, per this feature's hard requirement that
  audit:events-derived numbers (no organization_id on that stream) are
  never presented as tenant-scoped.
- aggregator.py additions (additive, built on PR-A): a per-org known-
  services index (drives /analytics/services) and an internal-only raw
  active-user-id reader (drives team-roster intersection, never exposed
  via any response).
- cache.py addition: get_or_set_async, the async twin of PR-A's
  get_or_set, for compute functions that call other services over http.
- main.py: mounted analytics_router, same per-route (not blanket)
  authorization posture routes_dashboard.py already established.

100% coverage on the entire analytics package (all 12 modules); full
existing suite (1151 tests) green at 99.84% overall, above the 98% gate.
@man4ish
man4ish merged commit 8779351 into main Aug 12, 2026
1 check passed
@man4ish
man4ish deleted the feature/analytics-c-api branch August 12, 2026 03:54
man4ish added a commit that referenced this pull request Aug 12, 2026
Fourth and final sub-PR. Frontend for the /analytics/* API (#39 PR-C).

- analytics.ts: data layer, mirroring interactions.ts's own shape --
  typed fetch wrappers for all 7 GET endpoints plus exportAnalyticsCsv
  (fetched, not a bare <a href>, so Authorization can be attached; blob
  download). /analytics/performance's response type pins org_id/team_id
  to `null` and scope to 'platform' at the type level, matching the
  backend's own hard requirement that those numbers are never presented
  as tenant-scoped.
- pages/AnalyticsDashboard.tsx: reuses the existing DashboardGrid/
  MetricCard widget family (PR10) and recharts (already a dependency,
  already used by BillingPage.tsx/EcosystemPage.tsx -- same chart colors,
  no new visual system). Overview cards, a queries/active-users trend
  line chart, DAU/WAU/MAU, a service breakdown table, performance cards
  (explicitly labeled platform-wide), 7d/30d/90d date presets, CSV
  export, and an organization filter shown only to a platform_admin (an
  org_admin/team_admin's scope is already fixed server-side to their own
  org/team). Null values render as "--", never a fabricated number, same
  convention DashboardPage.tsx already established.
- auth.ts: canSeeAnalytics(), a UX-only mirror of the backend's
  require_analytics_scope matrix (platform_admin/org_admin/team_admin
  allowed, regular member denied) built from the SessionUser fields
  Phase 1 PR3/Team Management v0.8.0 already put there (orgRoles,
  teamRole) -- no new claim, no new auth mechanism.
- navigation.ts / AdminApp.tsx: wired in exactly like 'interactions' was
  -- one NAV entry (Business section, next to Billing) gated by
  canSeeAnalytics, one switch case rendering AnalyticsDashboard.

Test plan: `npm test` -- 474 passed (38 files), no regressions.
AnalyticsDashboard.test.tsx (10 tests): loads and renders live data,
date-range re-fetch, API failure + retry, 403 permission-denied state,
empty-data state, null-value "--" rendering, CSV export (success +
failure), organization filter shown only for a platform_admin.
`tsc -b` and `vite build --mode admin` both clean.
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.

1 participant