mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 22:14:05 +00:00
Merge pull request #2822 from arc53-machine/feat/admin-activity-and-usage
feat(admin): activity feed, data-plane audit, and spend/latency in usage
This commit is contained in:
54 files changed
+4752
-343
No files matched your search
@@ -72,7 +72,7 @@ If your IdP only exposes groups via the userinfo endpoint, DocsGPT backfills the
|
||||
|
||||
## Admin dashboard
|
||||
|
||||
Admins get a dashboard backed by a REST surface under `/api/admin` (every endpoint requires the admin role). All mutating actions are written to the `auth_events` audit log with the acting admin recorded in the metadata.
|
||||
Admins get a dashboard backed by a REST surface under `/api/admin` (every endpoint requires the admin role). All mutating actions are written to the `auth_events` audit log with the acting admin recorded as the event's `actor_id`.
|
||||
|
||||
| Method | Path | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -84,8 +84,12 @@ Admins get a dashboard backed by a REST surface under `/api/admin` (every endpoi
|
||||
| `DELETE` | `/api/admin/users/<id>/role` | Revoke the admin role. |
|
||||
| `POST` | `/api/admin/users/<id>/revoke-sessions` | Force-logout a user. |
|
||||
| `GET` | `/api/admin/admins` | List current admins. |
|
||||
| `GET` | `/api/admin/usage` | Token-usage series and top users. |
|
||||
| `GET` | `/api/admin/audit` | Authentication/admin audit feed. |
|
||||
| `GET` | `/api/admin/usage` | Usage series with tokens and spend, per-model split, latency percentiles, top users. Takes `days`, `bucket` and `group_by=model\|agent\|source`. |
|
||||
| `GET` | `/api/admin/users/<id>/usage` | One user's spend: daily series plus a split by model and by flow. |
|
||||
| `GET` | `/api/admin/activity` | [Merged activity feed](#activity-feed) across all three audit journals. |
|
||||
| `GET` | `/api/admin/activity/events` | The `(event, category)` pairs this instance has recorded, for the filter UI. |
|
||||
| `GET` | `/api/admin/activity/export` | The filtered feed as `format=csv` or `format=ndjson`. |
|
||||
| `GET` | `/api/admin/audit` | Authentication/admin audit feed (`auth_events` only). |
|
||||
| `GET` | `/api/admin/devices/audit` | Remote-device audit feed. |
|
||||
| `GET` | `/api/admin/teams` | Instance-wide oversight of all teams. |
|
||||
| `GET` `PUT` `DELETE` | `/api/admin/quotas/...` | [Usage quotas](/Deploying/Usage-Quotas) for the instance, teams and users. |
|
||||
@@ -142,7 +146,36 @@ Sharing rules:
|
||||
|
||||
## Audit log
|
||||
|
||||
Access-control actions are appended to the `auth_events` table alongside the [authentication events](/Deploying/OIDC-SSO#login-auditing). This includes admin actions — `admin_user_activated` / `admin_user_deactivated`, `admin_sessions_revoked`, `role_granted` / `role_revoked` (with `metadata.source` = `manual` or `oidc_group`), `quota_policy_set` / `quota_policy_deleted` — and team events (`team.create`, `team.member_add`, `team.member_role`, `team.member_remove`, `team.share`, `team.unshare`, `team.transfer_owner`, `team.delete`). The acting admin is recorded in the event metadata.
|
||||
Access-control actions are appended to the `auth_events` table alongside the [authentication events](/Deploying/OIDC-SSO#login-auditing). This includes admin actions — `admin_user_activated` / `admin_user_deactivated`, `admin_sessions_revoked`, `role_granted` / `role_revoked` (with `metadata.source` = `manual` or `oidc_group`), `quota_policy_set` / `quota_policy_deleted` — and team events (`team.create`, `team.member_add`, `team.member_role`, `team.member_remove`, `team.share`, `team.unshare`, `team.transfer_owner`, `team.delete`).
|
||||
|
||||
Data-plane actions are recorded too: `source.created` / `source.deleted` / `source.reingested`, `agent.created` / `agent.updated` / `agent.deleted` / `agent.key_regenerated`, and `conversation.deleted` / `conversation.deleted_all`.
|
||||
|
||||
Every row carries `actor_id` (who did it) and `target_id` (the user it was done to, or `NULL` when the event is not about a user), so "everything this admin did" is a single query:
|
||||
|
||||
```sql
|
||||
SELECT created_at, event, target_id, metadata
|
||||
FROM auth_events
|
||||
WHERE actor_id = 'the-admin'
|
||||
ORDER BY created_at DESC;
|
||||
```
|
||||
|
||||
### Activity feed
|
||||
|
||||
The **Admin → Activity** tab merges three append-only journals into one timeline:
|
||||
|
||||
| Journal | Category | What it records |
|
||||
| --- | --- | --- |
|
||||
| `auth_events` | `identity`, `access`, `config`, `data` | Sign-ins, provisioning, role and account changes, quota changes, resource mutations. |
|
||||
| `device_audit_log` | `device` | Every remote-device command dispatch and its verdict. |
|
||||
| `guardrail_events` | `safety` | Every guardrail decision on a request. |
|
||||
|
||||
Filter by category, event name, actor, affected user, a time window, or a free-text search that reaches into each row's detail payload, then export the filtered feed as CSV or NDJSON. Exports stream and are capped at 100,000 rows — take a database dump for a full history.
|
||||
|
||||
An unknown filter value is refused with a 400 rather than dropped. Dropping it would leave that facet unfiltered, and no filter means every row, so a typo or a stale bookmark would silently widen an audit view instead of narrowing it.
|
||||
|
||||
The per-user panel in **Admin → Users** deliberately lists identity and access events only. Data-plane events are filed under the user who performed them, so on an active account routine activity would push a denied login or a role grant out of the window; use the Activity tab filtered by that user to see everything.
|
||||
|
||||
Two guardrail columns are deliberately never projected into the feed or the export: `api_key` (a raw agent key) and `matched_value` (unredacted source text). Admin-gating is not a reason to widen what a list response carries.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -185,7 +185,14 @@ SCIM identifies users by `userName`, which DocsGPT matches against its user id
|
||||
|
||||
## Login auditing
|
||||
|
||||
Authentication activity is recorded in `auth_events`, an append-only Postgres table carrying the user id, event name, IP address, user agent, a JSONB `metadata` column, and a timestamp:
|
||||
Authentication activity is recorded in `auth_events`, an append-only Postgres table carrying the event name, IP address, user agent, a JSONB `metadata` column, a timestamp, and two attribution columns:
|
||||
|
||||
- `actor_id` — who performed the action. Never empty.
|
||||
- `target_id` — the user the action was performed on, or `NULL` when the event is not about a user (a team change, an instance-wide policy).
|
||||
|
||||
They differ whenever someone acts on someone else: an admin deactivating an account is `actor_id = <the admin>`, `target_id = <the account>`. For self-service events such as a login they are the same user. Automated actors are named `system:oidc` (a group-driven role change at login) and `system:scim` (provisioning).
|
||||
|
||||
A `BEFORE INSERT` trigger fills both columns for any row that arrives without an `actor_id`, applying the same rules as the migration's backfill. That keeps attribution intact for a writer from a release that predates the columns — during a rolling deployment, for instance — instead of rejecting the insert or flattening the actor to a sentinel.
|
||||
|
||||
| Event | Recorded when |
|
||||
| --- | --- |
|
||||
@@ -197,14 +204,19 @@ Authentication activity is recorded in `auth_events`, an append-only Postgres ta
|
||||
| `role_granted` / `role_revoked` | The admin role is granted/revoked (`metadata.source` is `manual` or `oidc_group`). |
|
||||
| `admin_user_activated` / `admin_user_deactivated` | An admin activates/deactivates a user. |
|
||||
| `admin_sessions_revoked` | An admin force-logs-out a user. |
|
||||
| `pat_created` / `pat_revoked` / `pat_regenerated` | Personal access token lifecycle. |
|
||||
| `quota_policy_set` / `quota_policy_deleted` | A [usage quota](/Deploying/Usage-Quotas) is changed. |
|
||||
| `team.*` | Team management — `team.create`, `team.member_add`, `team.member_role`, `team.member_remove`, `team.share`, `team.unshare`, `team.transfer_owner`, `team.delete`. |
|
||||
| `source.*` | `source.created`, `source.deleted`, `source.reingested`. |
|
||||
| `agent.*` | `agent.created`, `agent.updated`, `agent.deleted`, `agent.key_regenerated`. |
|
||||
| `conversation.*` | `conversation.deleted`, `conversation.deleted_all`. |
|
||||
|
||||
The acting admin is recorded in the event metadata. See [Access Control, Roles & Teams](/Deploying/Access-Control) for the admin and team features that emit these events.
|
||||
`agent.updated` records the names of the fields that changed, never their values — an agent's fields carry prompts and credentials.
|
||||
|
||||
There is no UI for these events yet — query the table directly:
|
||||
See [Access Control, Roles & Teams](/Deploying/Access-Control#activity-feed) for the admin Activity view that reads these events, and for the two other journals it merges them with. To query the table directly:
|
||||
|
||||
```sql
|
||||
SELECT created_at, event, user_id, ip, metadata
|
||||
SELECT created_at, event, actor_id, target_id, ip, metadata
|
||||
FROM auth_events
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""0034 auth events actor/target — who did it, and to whom.
|
||||
|
||||
``auth_events.user_id`` was overloaded: admin mutations file the row under the
|
||||
*target* user and hide the acting admin in ``metadata->>'by'``, team events file
|
||||
it under the *actor*, and quota events switch between the two depending on
|
||||
scope. The practical consequence is that "show me everything admin X did" is
|
||||
unanswerable — the first question any audit review asks.
|
||||
|
||||
This adds two explicit columns:
|
||||
|
||||
* ``actor_id`` — who performed the action (never NULL going forward).
|
||||
* ``target_id`` — the user the action was performed on; NULL when the event is
|
||||
not about a user (a team or an instance-wide policy change).
|
||||
|
||||
``user_id`` is deliberately left alone. It stays the per-user feed key
|
||||
(``list_recent``) and every historical row keeps its meaning, so this migration
|
||||
is additive and the backfill never rewrites it.
|
||||
|
||||
Backfill rules, applied only to rows that predate the columns:
|
||||
|
||||
* ``actor_id`` = the first present of ``metadata->>'by'``,
|
||||
``metadata->>'granted_by'``, ``metadata->>'revoked_by'``, else ``user_id``.
|
||||
* ``target_id`` = ``user_id``, except for events that were always filed under
|
||||
the actor and have no single user target: ``team.*``, and instance- or
|
||||
team-scoped ``quota_policy_*`` changes.
|
||||
|
||||
The same two rules are installed as SQL functions and applied by a BEFORE
|
||||
INSERT trigger to any row that arrives without an ``actor_id``, so a writer
|
||||
from a release that predates these columns keeps its attribution instead of
|
||||
being rejected or flattened to a sentinel.
|
||||
|
||||
Also adds the indexes the global admin feed needs. Before this the only index
|
||||
was ``(user_id, created_at DESC)``, so the cross-user feed — which orders by
|
||||
``created_at DESC`` with no user predicate — degraded to a sequential scan plus
|
||||
a sort on every page.
|
||||
|
||||
Idempotent both ways.
|
||||
|
||||
Revision ID: 0034_auth_events_actor_target
|
||||
Revises: 0033_quotas
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0034_auth_events_actor_target"
|
||||
down_revision: Union[str, None] = "0033_quotas"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE auth_events ADD COLUMN IF NOT EXISTS actor_id TEXT;")
|
||||
op.execute("ALTER TABLE auth_events ADD COLUMN IF NOT EXISTS target_id TEXT;")
|
||||
|
||||
# The derivation rules, as functions, so the backfill below and the
|
||||
# compatibility trigger share one definition instead of two copies of the
|
||||
# same CASE drifting apart.
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION auth_events_derive_actor(
|
||||
metadata jsonb, user_id text
|
||||
) RETURNS text
|
||||
LANGUAGE sql IMMUTABLE AS $$
|
||||
SELECT COALESCE(
|
||||
metadata->>'by',
|
||||
metadata->>'granted_by',
|
||||
metadata->>'revoked_by',
|
||||
user_id
|
||||
);
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION auth_events_derive_target(
|
||||
event text, metadata jsonb, user_id text
|
||||
) RETURNS text
|
||||
LANGUAGE sql IMMUTABLE AS $$
|
||||
SELECT CASE
|
||||
-- Team events were always filed under the actor.
|
||||
WHEN event LIKE 'team.%' THEN NULL
|
||||
-- An instance or team quota policy is filed under the acting
|
||||
-- admin, not a target user (see api/admin/quotas.py::_audit);
|
||||
-- only a user-scoped policy has one.
|
||||
WHEN event IN ('quota_policy_set', 'quota_policy_deleted')
|
||||
AND COALESCE(metadata->>'scope', '') <> 'user' THEN NULL
|
||||
ELSE user_id
|
||||
END;
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Backfill. ``actor_id IS NULL`` scopes this to pre-migration rows, so a
|
||||
# re-run (or a downgrade/upgrade cycle) never clobbers written values.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE auth_events
|
||||
SET actor_id = auth_events_derive_actor(metadata, user_id),
|
||||
target_id = auth_events_derive_target(event, metadata, user_id)
|
||||
WHERE actor_id IS NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
# A writer from a release that predates these columns names only the old
|
||||
# ones, so ``actor_id`` arrives NULL. Two things follow, and the trigger
|
||||
# handles both:
|
||||
#
|
||||
# 1. The NOT NULL below would reject the insert. In admin/routes.py that
|
||||
# insert shares the request's transaction, so a NotNullViolation would
|
||||
# 500 the deactivate or role grant and roll its write back with it.
|
||||
# 2. Defaulting it to a sentinel would keep the write but lose the
|
||||
# attribution -- and the highest-volume events are the recoverable
|
||||
# ones: for a login or a silent renewal the actor is simply the user.
|
||||
#
|
||||
# ``actor_id IS NULL`` identifies a legacy insert exactly, because the
|
||||
# repository always supplies one. That matters for ``target_id``: a current
|
||||
# writer sets it to NULL deliberately for events with no user target
|
||||
# (``conversation.deleted``), and deriving there would wrongly name the
|
||||
# actor as their own target.
|
||||
#
|
||||
# Kept rather than dropped in a follow-up: it costs nothing on the path the
|
||||
# repository takes, and it keeps the column's contract true for any writer
|
||||
# that bypasses it.
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION auth_events_fill_attribution()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.actor_id IS NULL THEN
|
||||
NEW.actor_id := auth_events_derive_actor(
|
||||
NEW.metadata, NEW.user_id
|
||||
);
|
||||
NEW.target_id := auth_events_derive_target(
|
||||
NEW.event, NEW.metadata, NEW.user_id
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
op.execute("DROP TRIGGER IF EXISTS auth_events_fill_attribution_trg ON auth_events;")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER auth_events_fill_attribution_trg
|
||||
BEFORE INSERT ON auth_events
|
||||
FOR EACH ROW EXECUTE FUNCTION auth_events_fill_attribution();
|
||||
"""
|
||||
)
|
||||
|
||||
# Backfilled above and guaranteed by the trigger, so this is safe to assert.
|
||||
op.execute("ALTER TABLE auth_events ALTER COLUMN actor_id SET NOT NULL;")
|
||||
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS auth_events_created_idx "
|
||||
"ON auth_events (created_at DESC);"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS auth_events_event_created_idx "
|
||||
"ON auth_events (event, created_at DESC);"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS auth_events_actor_idx "
|
||||
"ON auth_events (actor_id, created_at DESC);"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS auth_events_actor_idx;")
|
||||
op.execute("DROP INDEX IF EXISTS auth_events_event_created_idx;")
|
||||
op.execute("DROP INDEX IF EXISTS auth_events_created_idx;")
|
||||
op.execute("DROP TRIGGER IF EXISTS auth_events_fill_attribution_trg ON auth_events;")
|
||||
op.execute("DROP FUNCTION IF EXISTS auth_events_fill_attribution();")
|
||||
op.execute("ALTER TABLE auth_events DROP COLUMN IF EXISTS target_id;")
|
||||
op.execute("ALTER TABLE auth_events DROP COLUMN IF EXISTS actor_id;")
|
||||
op.execute("DROP FUNCTION IF EXISTS auth_events_derive_target(text, jsonb, text);")
|
||||
op.execute("DROP FUNCTION IF EXISTS auth_events_derive_actor(jsonb, text);")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""0035 token usage latency — how long the call took, and time to first token.
|
||||
|
||||
Nothing in the schema recorded how long an LLM call took. The wrappers in
|
||||
``docsgpt/usage.py`` already measured it for the ``llm_gen_finished`` /
|
||||
``llm_stream_finished`` log lines and then threw the number away, so an
|
||||
operator could see what an instance spent but never how slow it was.
|
||||
|
||||
Two columns, both nullable because both are genuinely unknown for some rows:
|
||||
|
||||
* ``duration_ms`` — wall-clock for the whole call.
|
||||
* ``ttft_ms`` — time to the first streamed chunk. NULL on non-streaming calls
|
||||
and on streams that failed before yielding anything, which is the honest
|
||||
reading: "no first token", not "instant".
|
||||
|
||||
NULL also means "written before this migration", so historical rows never
|
||||
drag a p50 towards zero.
|
||||
|
||||
Idempotent both ways.
|
||||
|
||||
Revision ID: 0035_token_usage_latency
|
||||
Revises: 0034_auth_events_actor_target
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0035_token_usage_latency"
|
||||
down_revision: Union[str, None] = "0034_auth_events_actor_target"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE token_usage ADD COLUMN IF NOT EXISTS duration_ms INTEGER;")
|
||||
op.execute("ALTER TABLE token_usage ADD COLUMN IF NOT EXISTS ttft_ms INTEGER;")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE token_usage DROP COLUMN IF EXISTS ttft_ms;")
|
||||
op.execute("ALTER TABLE token_usage DROP COLUMN IF EXISTS duration_ms;")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""0036 device audit feed index — created_at for the merged activity feed.
|
||||
|
||||
``device_audit_log`` carried only ``(device_id, created_at DESC)`` and
|
||||
``(user_id, created_at DESC)``. Both serve a per-device or per-user lookup;
|
||||
neither can serve the merged admin activity feed, which orders by
|
||||
``created_at DESC`` across every user and device, so that branch of the union
|
||||
was sequentially scanned on every page.
|
||||
|
||||
``auth_events`` got the equivalent index in 0034 and ``guardrail_events`` has
|
||||
had ``ix_guardrail_events_created`` since 0029; this closes the third journal.
|
||||
|
||||
Idempotent both ways.
|
||||
|
||||
Revision ID: 0036_device_audit_created_idx
|
||||
Revises: 0035_token_usage_latency
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0036_device_audit_created_idx"
|
||||
down_revision: Union[str, None] = "0035_token_usage_latency"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS device_audit_created_idx "
|
||||
"ON device_audit_log (created_at DESC);"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS device_audit_created_idx;")
|
||||
@@ -1,4 +1,5 @@
|
||||
from .routes import admin_ns
|
||||
from . import quotas # noqa: F401 (registers the quota resources on admin_ns)
|
||||
from . import activity # noqa: F401 (registers the activity resources on admin_ns)
|
||||
|
||||
__all__ = ["admin_ns"]
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Admin activity feed: one timeline over all three audit journals.
|
||||
|
||||
``GET /api/admin/audit`` remains the ``auth_events``-only feed. These endpoints
|
||||
add the merged view an incident review actually wants — identity, access,
|
||||
configuration, data-plane, remote-device and guardrail activity in one ordered
|
||||
list — plus the filters and the export that make a review possible without
|
||||
shelling into the database.
|
||||
|
||||
Every resource here is ``@admin_required``, like the rest of the namespace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterator, Optional
|
||||
|
||||
from flask import Response, jsonify, make_response, request, stream_with_context
|
||||
from flask_restx import Resource
|
||||
|
||||
from docsgpt.api.admin.routes import _int_arg, _page, admin_ns
|
||||
from docsgpt.api.user.authz import admin_required
|
||||
from docsgpt.audit_events import ACTIVITY_CATEGORIES
|
||||
from docsgpt.storage.db.repositories.activity import (
|
||||
ACTIVITY_COLUMNS,
|
||||
ActivityRepository,
|
||||
)
|
||||
from docsgpt.storage.db.session import db_readonly
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_FEEDS = ("auth", "device", "guardrail")
|
||||
_EXPORT_FORMATS = ("csv", "ndjson")
|
||||
|
||||
# An export runs against a live instance, so it is bounded. Operators needing
|
||||
# the full history take a database dump, not an HTTP request.
|
||||
_EXPORT_MAX_ROWS = 100_000
|
||||
_EXPORT_CHUNK = 1_000
|
||||
|
||||
_MAX_SEARCH_LENGTH = 200
|
||||
|
||||
|
||||
class _InvalidFilter(ValueError):
|
||||
"""A filter argument the caller got wrong. Surfaces as a 400."""
|
||||
|
||||
|
||||
def _facet_list(name: str, allowed: tuple[str, ...]) -> Optional[list[str]]:
|
||||
"""Parse a repeated/comma-separated query arg into known facet values.
|
||||
|
||||
Rejects unknown values rather than dropping them. Dropping would leave the
|
||||
request with no filter on that facet, and "no filter" means *everything* --
|
||||
so a typo or a stale chip would silently widen an audit view instead of
|
||||
narrowing it, and on the export would stream the whole history.
|
||||
|
||||
Args:
|
||||
name: Query parameter name.
|
||||
allowed: The permitted values.
|
||||
|
||||
Returns:
|
||||
The distinct values in the order given, or None when absent.
|
||||
|
||||
Raises:
|
||||
_InvalidFilter: If any supplied value is not in ``allowed``.
|
||||
"""
|
||||
raw: list[str] = []
|
||||
for value in request.args.getlist(name):
|
||||
raw.extend(part.strip() for part in value.split(",") if part.strip())
|
||||
seen: list[str] = []
|
||||
for value in raw:
|
||||
if value not in allowed:
|
||||
raise _InvalidFilter(
|
||||
f"Unknown {name}: {value!r}. Expected one of {', '.join(allowed)}."
|
||||
)
|
||||
if value not in seen:
|
||||
seen.append(value)
|
||||
return seen or None
|
||||
|
||||
|
||||
def _event_list() -> Optional[list[str]]:
|
||||
"""Event names to filter on. Unconstrained — the catalogue is data, not an enum."""
|
||||
names: list[str] = []
|
||||
for value in request.args.getlist("event"):
|
||||
for part in value.split(","):
|
||||
part = part.strip()
|
||||
if part and part not in names:
|
||||
names.append(part)
|
||||
return names or None
|
||||
|
||||
|
||||
def _time_arg(name: str) -> Optional[datetime]:
|
||||
"""Parse an ISO-8601 timestamp query arg, or None when absent.
|
||||
|
||||
Raises:
|
||||
_InvalidFilter: If present but unparseable. Silently ignoring it would
|
||||
drop the time bound and return the full history.
|
||||
"""
|
||||
raw = (request.args.get(name) or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise _InvalidFilter(
|
||||
f"{name} must be an ISO-8601 timestamp, got {raw!r}."
|
||||
) from exc
|
||||
# A naive bound is read as UTC, matching how the feed stores timestamps.
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _search_arg() -> Optional[str]:
|
||||
value = (request.args.get("search") or "").strip()
|
||||
return value[:_MAX_SEARCH_LENGTH] or None
|
||||
|
||||
|
||||
def _filters() -> dict:
|
||||
"""The filter set shared by the feed and the export."""
|
||||
return {
|
||||
"feeds": _facet_list("feed", _FEEDS),
|
||||
"categories": _facet_list("category", ACTIVITY_CATEGORIES),
|
||||
"events": _event_list(),
|
||||
"actor_id": request.args.get("actor_id") or None,
|
||||
"user_id": request.args.get("user_id") or None,
|
||||
"since": _time_arg("since"),
|
||||
"until": _time_arg("until"),
|
||||
"search": _search_arg(),
|
||||
}
|
||||
|
||||
|
||||
def _serialize(row: dict) -> dict:
|
||||
"""Make one feed row JSON-safe (timestamps to ISO-8601)."""
|
||||
created = row.get("created_at")
|
||||
return {
|
||||
**row,
|
||||
"created_at": created.isoformat() if hasattr(created, "isoformat") else created,
|
||||
}
|
||||
|
||||
|
||||
@admin_ns.route("/admin/activity")
|
||||
class AdminActivityResource(Resource):
|
||||
@admin_required
|
||||
def get(self):
|
||||
"""Merged audit feed, newest first, with filters and a total."""
|
||||
page, page_size, offset = _page()
|
||||
try:
|
||||
filters = _filters()
|
||||
except _InvalidFilter as exc:
|
||||
return make_response(jsonify({"success": False, "message": str(exc)}), 400)
|
||||
with db_readonly() as conn:
|
||||
repo = ActivityRepository(conn)
|
||||
total = repo.count(**filters)
|
||||
rows = repo.list(limit=page_size, offset=offset, **filters)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"activity": [_serialize(row) for row in rows],
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
"has_more": offset + len(rows) < total,
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@admin_ns.route("/admin/activity/events")
|
||||
class AdminActivityCatalogueResource(Resource):
|
||||
@admin_required
|
||||
def get(self):
|
||||
"""Distinct ``(event, category)`` pairs this instance has recorded.
|
||||
|
||||
The filter UI offers these instead of a free-text box, so an operator
|
||||
never has to guess that denied logins are ``oidc_login_denied``.
|
||||
"""
|
||||
with db_readonly() as conn:
|
||||
catalogue = ActivityRepository(conn).event_names()
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"events": catalogue,
|
||||
"categories": list(ACTIVITY_CATEGORIES),
|
||||
"feeds": list(_FEEDS),
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@admin_ns.route("/admin/activity/export")
|
||||
class AdminActivityExportResource(Resource):
|
||||
@admin_required
|
||||
def get(self):
|
||||
"""Stream the filtered feed as CSV or NDJSON.
|
||||
|
||||
Streamed, not buffered: a compliance export of a busy instance should
|
||||
not be built in memory first. Capped at ``_EXPORT_MAX_ROWS``, and the
|
||||
``X-Export-Max-Rows`` header reports the cap that was in effect so a
|
||||
truncated export is not mistaken for a complete one.
|
||||
"""
|
||||
fmt = (request.args.get("format") or "csv").lower()
|
||||
if fmt not in _EXPORT_FORMATS:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Invalid format"}), 400
|
||||
)
|
||||
limit = max(1, min(_EXPORT_MAX_ROWS, _int_arg("max_rows", _EXPORT_MAX_ROWS)))
|
||||
try:
|
||||
filters = _filters()
|
||||
except _InvalidFilter as exc:
|
||||
return make_response(jsonify({"success": False, "message": str(exc)}), 400)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
generator = _csv_rows if fmt == "csv" else _ndjson_rows
|
||||
return Response(
|
||||
stream_with_context(generator(filters, limit)),
|
||||
mimetype="text/csv" if fmt == "csv" else "application/x-ndjson",
|
||||
headers={
|
||||
"Content-Disposition": (
|
||||
f'attachment; filename="docsgpt-activity-{stamp}.{fmt}"'
|
||||
),
|
||||
# The cap in effect. Without it a truncated export reads as
|
||||
# a complete one.
|
||||
"X-Export-Max-Rows": str(limit),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _export_rows(filters: dict, limit: int) -> Iterator[dict]:
|
||||
"""Walk the filtered feed, holding the connection open for the whole stream."""
|
||||
with db_readonly() as conn:
|
||||
yield from ActivityRepository(conn).iter_all(
|
||||
chunk_size=_EXPORT_CHUNK, max_rows=limit, **filters
|
||||
)
|
||||
|
||||
|
||||
# Characters a spreadsheet reads as the start of a formula.
|
||||
_FORMULA_TRIGGERS = ("=", "+", "-", "@", "\t", "\r")
|
||||
|
||||
|
||||
def _csv_safe(value: object) -> object:
|
||||
"""Neutralize a leading formula trigger before the value becomes a CSV cell.
|
||||
|
||||
The feed carries attacker-controlled text: ``user_agent`` is the raw header
|
||||
of whoever made the request, and a denied login records one without ever
|
||||
authenticating. An export is opened in a spreadsheet by an admin, so a cell
|
||||
beginning ``=`` would be evaluated there. Prefixing an apostrophe keeps the
|
||||
value readable and inert.
|
||||
"""
|
||||
if isinstance(value, str) and value.startswith(_FORMULA_TRIGGERS):
|
||||
return f"'{value}"
|
||||
return value
|
||||
|
||||
|
||||
def _csv_rows(filters: dict, limit: int) -> Iterator[str]:
|
||||
buffer = io.StringIO()
|
||||
writer = csv.DictWriter(buffer, fieldnames=list(ACTIVITY_COLUMNS))
|
||||
writer.writeheader()
|
||||
yield _drain(buffer)
|
||||
for row in _export_rows(filters, limit):
|
||||
serialized = _serialize(row)
|
||||
# ``detail`` is a JSON object; a CSV cell holds its compact encoding.
|
||||
serialized["detail"] = json.dumps(
|
||||
serialized.get("detail") or {}, default=str
|
||||
)
|
||||
writer.writerow({key: _csv_safe(value) for key, value in serialized.items()})
|
||||
yield _drain(buffer)
|
||||
|
||||
|
||||
def _ndjson_rows(filters: dict, limit: int) -> Iterator[str]:
|
||||
for row in _export_rows(filters, limit):
|
||||
yield json.dumps(_serialize(row), default=str) + "\n"
|
||||
|
||||
|
||||
def _drain(buffer: io.StringIO) -> str:
|
||||
"""Return and clear the writer's buffer, so each row streams as written."""
|
||||
value = buffer.getvalue()
|
||||
buffer.seek(0)
|
||||
buffer.truncate(0)
|
||||
return value
|
||||
@@ -119,6 +119,10 @@ def _audit(conn, event: str, scope: str, subject_id: Optional[str], detail: dict
|
||||
ip=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
metadata={"by": actor, "via": "admin_api", "scope": scope, "subject_id": subject_id, **detail},
|
||||
actor_id=actor or "unknown",
|
||||
# Only a user-scoped policy targets a user; instance and team policies
|
||||
# change configuration, not an account.
|
||||
target_id=subject_id if scope == "user" else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ admin_ns = Namespace("admin", description="Admin-only management endpoints", pat
|
||||
_DEFAULT_PAGE_SIZE = 25
|
||||
_MAX_PAGE_SIZE = 100
|
||||
|
||||
# Event namespaces the per-user security panel leaves out; see the drill-down.
|
||||
_DATA_PLANE_PREFIXES = ("source.", "agent.", "conversation.")
|
||||
|
||||
|
||||
def _int_arg(name: str, default: int) -> int:
|
||||
try:
|
||||
@@ -135,8 +138,12 @@ class AdminUserResource(Resource):
|
||||
},
|
||||
"roles": sorted({"user", *roles_repo.role_names_for(user_id)}),
|
||||
"grants": roles_repo.list_for(user_id),
|
||||
# Identity and access only. Data-plane events file under the
|
||||
# actor, so an active user's routine deletes would push a
|
||||
# denied login or a role grant out of a 20-row window.
|
||||
# The Activity tab, filtered by user, shows everything.
|
||||
"recent_events": AuthEventsRepository(conn).list_recent(
|
||||
user_id, limit=20
|
||||
user_id, limit=20, exclude_events_like=_DATA_PLANE_PREFIXES
|
||||
),
|
||||
"counts": AdminStatsRepository(conn).user_counts(user_id),
|
||||
}
|
||||
@@ -171,6 +178,8 @@ class AdminUserResource(Resource):
|
||||
ip=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
metadata={"by": actor, "via": "admin_api"},
|
||||
actor_id=actor,
|
||||
target_id=user_id,
|
||||
)
|
||||
if not active:
|
||||
# Best-effort live-session revocation (mirrors SCIM deactivation).
|
||||
@@ -203,6 +212,8 @@ class AdminUserRoleResource(Resource):
|
||||
"granted_by": actor,
|
||||
"via": "admin_api",
|
||||
},
|
||||
actor_id=actor,
|
||||
target_id=user_id,
|
||||
)
|
||||
return make_response(
|
||||
jsonify({"success": True, "granted": inserted, "role": ROLE_ADMIN}), 200
|
||||
@@ -238,6 +249,8 @@ class AdminUserRoleResource(Resource):
|
||||
"revoked_by": actor,
|
||||
"via": "admin_api",
|
||||
},
|
||||
actor_id=actor,
|
||||
target_id=user_id,
|
||||
)
|
||||
return make_response(jsonify({"success": True, "revoked": removed}), 200)
|
||||
|
||||
@@ -261,6 +274,8 @@ class AdminUserSessionsResource(Resource):
|
||||
ip=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
metadata={"token_id": token_id, "by": _actor(), "via": "admin_sessions_revoked"},
|
||||
actor_id=_actor(),
|
||||
target_id=user_id,
|
||||
)
|
||||
AuthEventsRepository(conn).insert(
|
||||
user_id,
|
||||
@@ -273,10 +288,32 @@ class AdminUserSessionsResource(Resource):
|
||||
"persisted": ok,
|
||||
"personal_access_tokens_revoked": len(revoked_token_ids),
|
||||
},
|
||||
actor_id=_actor(),
|
||||
target_id=user_id,
|
||||
)
|
||||
return make_response(jsonify({"success": True, "revoked": ok}), 200)
|
||||
|
||||
|
||||
@admin_ns.route("/admin/users/<string:user_id>/usage")
|
||||
class AdminUserUsageResource(Resource):
|
||||
@admin_required
|
||||
def get(self, user_id):
|
||||
"""One user's spend: daily series plus a split by model and by flow."""
|
||||
days = max(1, min(365, _int_arg("days", 30)))
|
||||
start = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
with db_readonly() as conn:
|
||||
series = TokenUsageRepository(conn).bucketed_totals(
|
||||
bucket_unit="day", user_id=user_id, timestamp_gte=start
|
||||
)
|
||||
breakdown = AdminStatsRepository(conn).user_usage_breakdown(
|
||||
user_id, since=start
|
||||
)
|
||||
return make_response(
|
||||
jsonify({"success": True, "days": days, "series": series, **breakdown}),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@admin_ns.route("/admin/admins")
|
||||
class AdminAdminsResource(Resource):
|
||||
@admin_required
|
||||
@@ -294,7 +331,12 @@ class AdminUsageResource(Resource):
|
||||
|
||||
@admin_required
|
||||
def get(self):
|
||||
"""Global token usage: time-bucketed series + total + top users."""
|
||||
"""Global usage: time-bucketed series, totals, spend, latency, top users.
|
||||
|
||||
Every bucket carries both tokens and USD cost. Quota policies have
|
||||
always been settable in dollars; until now nothing showed the spend
|
||||
they were capping.
|
||||
"""
|
||||
days = max(1, min(365, _int_arg("days", 30)))
|
||||
bucket = request.args.get("bucket", "day")
|
||||
group_by = request.args.get("group_by", "none")
|
||||
@@ -303,6 +345,7 @@ class AdminUsageResource(Resource):
|
||||
start = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
with db_readonly() as conn:
|
||||
usage_repo = TokenUsageRepository(conn)
|
||||
stats_repo = AdminStatsRepository(conn)
|
||||
series = usage_repo.bucketed_totals(
|
||||
bucket_unit=bucket,
|
||||
timestamp_gte=start,
|
||||
@@ -311,7 +354,8 @@ class AdminUsageResource(Resource):
|
||||
total = usage_repo.sum_tokens_in_range(
|
||||
start=start, end=datetime.now(timezone.utc)
|
||||
)
|
||||
top_users = AdminStatsRepository(conn).top_token_users(since=start, limit=10)
|
||||
latency = stats_repo.latency_summary(since=start)
|
||||
top_users = stats_repo.top_token_users(since=start, limit=10)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
@@ -321,6 +365,10 @@ class AdminUsageResource(Resource):
|
||||
"group_by": group_by,
|
||||
"series": series,
|
||||
"total_tokens": int(total),
|
||||
# Summed from the series, not from ``by_model``: the latter
|
||||
# drops rows with no model_id, so it would undercount.
|
||||
"total_cost": round(sum(row["cost"] for row in series), 4),
|
||||
"latency": latency,
|
||||
"top_users": top_users,
|
||||
}
|
||||
),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Shared audit-trail helper for recording data-plane and identity events.
|
||||
|
||||
Identity and access events (login, role grants, provisioning) have been
|
||||
audited since ``auth_events`` was introduced. Data-plane actions — creating and
|
||||
deleting sources, agents, agent keys and conversations — were not, which left
|
||||
the trail unable to answer "who deleted that source". :func:`record_event` is
|
||||
the one-line hook those routes use.
|
||||
|
||||
Two properties matter and are enforced here rather than at every call site:
|
||||
|
||||
1. **An audit write can never fail the action it records.** The insert runs in
|
||||
a SAVEPOINT so a rejected row rolls back alone, and any exception is
|
||||
swallowed with a log line.
|
||||
2. **Request context is optional.** Celery tasks (ingestion finishing, a
|
||||
scheduled run) have no Flask request; the row simply records without an IP
|
||||
or user agent instead of raising.
|
||||
|
||||
The event → category taxonomy the admin activity feed filters on lives in
|
||||
``docsgpt/audit_events.py``, which the storage layer needs too.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask import has_request_context, request
|
||||
from sqlalchemy import Connection
|
||||
|
||||
from docsgpt.storage.db.repositories.auth_events import AuthEventsRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def record_event(
|
||||
conn: Connection,
|
||||
event: str,
|
||||
*,
|
||||
actor: Optional[str],
|
||||
target: Optional[str] = None,
|
||||
**metadata: Any,
|
||||
) -> None:
|
||||
"""Append one audit event, best-effort, inside the caller's transaction.
|
||||
|
||||
Args:
|
||||
conn: Open SQLAlchemy ``Connection`` -- not a Session -- inside the
|
||||
action's transaction, so the audit row commits atomically with the
|
||||
change it describes and ``begin_nested`` gives it a real SAVEPOINT.
|
||||
event: Dotted event name (``source.deleted``, ``agent.created``).
|
||||
actor: Who performed the action; ``"unknown"`` when unauthenticated.
|
||||
target: The user acted upon, when the event is about a user. Data-plane
|
||||
events act on a resource, not an account, so they leave this None.
|
||||
**metadata: Free-form detail for the audit drill-down. ``None`` values
|
||||
are dropped so the stored object only carries what was known.
|
||||
"""
|
||||
detail = {key: value for key, value in metadata.items() if value is not None}
|
||||
ip = request.remote_addr if has_request_context() else None
|
||||
user_agent = request.headers.get("User-Agent") if has_request_context() else None
|
||||
resolved_actor = actor or "unknown"
|
||||
try:
|
||||
with conn.begin_nested():
|
||||
AuthEventsRepository(conn).insert(
|
||||
# ``user_id`` is the per-user feed key: the target when the
|
||||
# event is about someone, else the actor's own trail.
|
||||
user_id=target or resolved_actor,
|
||||
event=event,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
metadata=detail,
|
||||
actor_id=resolved_actor,
|
||||
target_id=target,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("audit insert failed for event=%s", event, exc_info=True)
|
||||
@@ -26,7 +26,10 @@ from docsgpt.api.oidc import denylist, provider
|
||||
from docsgpt.auth import handle_auth
|
||||
from docsgpt.cache import get_redis_instance
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.repositories.auth_events import AuthEventsRepository
|
||||
from docsgpt.storage.db.repositories.auth_events import (
|
||||
SYSTEM_ACTOR_OIDC,
|
||||
AuthEventsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.user_roles import UserRolesRepository
|
||||
from docsgpt.storage.db.repositories.users import UsersRepository
|
||||
from docsgpt.storage.db.session import db_readonly, db_session
|
||||
@@ -129,6 +132,9 @@ def _reconcile_oidc_admin(user_id: str, groups: list[str] | None) -> None:
|
||||
ip=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
metadata={"role": "admin", "source": "oidc_group"},
|
||||
# The provider's group membership drove this, not the user.
|
||||
actor_id=SYSTEM_ACTOR_OIDC,
|
||||
target_id=user_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.error("OIDC admin reconcile failed for %s", user_id, exc_info=True)
|
||||
|
||||
@@ -307,6 +307,8 @@ class AdminToken(Resource):
|
||||
ip=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
metadata={"token_id": token_id, "by": actor, "via": "admin_api"},
|
||||
actor_id=actor,
|
||||
target_id=row["user_id"],
|
||||
)
|
||||
if not revoked:
|
||||
return _error("Token not found", 404)
|
||||
|
||||
@@ -20,7 +20,10 @@ from sqlalchemy import Connection
|
||||
|
||||
from docsgpt.api.oidc.denylist import deny_user
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.repositories.auth_events import AuthEventsRepository
|
||||
from docsgpt.storage.db.repositories.auth_events import (
|
||||
SYSTEM_ACTOR_SCIM,
|
||||
AuthEventsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.users import UsersRepository
|
||||
from docsgpt.storage.db.session import db_readonly, db_session
|
||||
|
||||
@@ -201,7 +204,13 @@ def _audit(conn: Connection, user_id: str, event: str) -> None:
|
||||
"""Best-effort audit insert in a savepoint; failure never fails the request."""
|
||||
try:
|
||||
with conn.begin_nested():
|
||||
AuthEventsRepository(conn).insert(user_id, event, metadata={"via": "scim"})
|
||||
AuthEventsRepository(conn).insert(
|
||||
user_id,
|
||||
event,
|
||||
metadata={"via": "scim"},
|
||||
actor_id=SYSTEM_ACTOR_SCIM,
|
||||
target_id=user_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.error("SCIM audit insert failed for user %s event %s", user_id, event, exc_info=True)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from flask_restx import fields, Namespace, Resource
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.audit import record_event
|
||||
from docsgpt.api.pat.rules import filter_listing, mask_agent_key, may_see_agent_keys
|
||||
from docsgpt.guardrails.config import AgentConfig
|
||||
from docsgpt.api.user.base import (
|
||||
@@ -760,6 +761,15 @@ class CreateAgent(Resource):
|
||||
**kwargs,
|
||||
)
|
||||
new_id = str(agent_row["id"])
|
||||
record_event(
|
||||
conn,
|
||||
"agent.created",
|
||||
actor=user,
|
||||
agent_id=new_id,
|
||||
name=data["name"],
|
||||
status=data["status"],
|
||||
agent_type=agent_type,
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error creating agent: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
@@ -1284,6 +1294,16 @@ class UpdateAgent(Resource):
|
||||
updated = bool(result)
|
||||
else:
|
||||
updated = agents_repo.update(pg_agent_id, user, update_fields)
|
||||
# An update that changed no field is not an event.
|
||||
if updated and update_fields:
|
||||
record_event(
|
||||
conn,
|
||||
"agent.updated",
|
||||
actor=user,
|
||||
agent_id=pg_agent_id,
|
||||
# Names only: values can hold prompts and credentials.
|
||||
fields=sorted(update_fields),
|
||||
)
|
||||
if not updated:
|
||||
return make_response(
|
||||
jsonify(
|
||||
@@ -1403,6 +1423,13 @@ class RegenerateAgentKey(Resource):
|
||||
SharedConversationsRepository(conn).reassign_api_key(
|
||||
old_key=old_key, new_key=new_key
|
||||
)
|
||||
record_event(
|
||||
conn,
|
||||
"agent.key_regenerated",
|
||||
actor=user,
|
||||
agent_id=pg_agent_id,
|
||||
name=existing_agent.get("name"),
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error regenerating agent key: {err}", exc_info=True
|
||||
@@ -1457,6 +1484,14 @@ class DeleteAgent(Resource):
|
||||
agents_repo.delete(pg_agent_id, user)
|
||||
# Strip pinned/shared entries for this agent from the owner's prefs.
|
||||
UsersRepository(conn).remove_agent_from_all(user, pg_agent_id)
|
||||
record_event(
|
||||
conn,
|
||||
"agent.deleted",
|
||||
actor=user,
|
||||
agent_id=pg_agent_id,
|
||||
name=agent.get("name"),
|
||||
agent_type=agent.get("agent_type"),
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error deleting agent: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
|
||||
@@ -7,6 +7,7 @@ from flask_restx import fields, Namespace, Resource
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.audit import record_event
|
||||
from docsgpt.api.answer.services.conversation_service import (
|
||||
TERMINATED_RESPONSE_PLACEHOLDER,
|
||||
)
|
||||
@@ -45,6 +46,12 @@ class DeleteConversation(Resource):
|
||||
conv = repo.get_any(conversation_id, user_id)
|
||||
if conv is not None:
|
||||
repo.delete(str(conv["id"]), user_id)
|
||||
record_event(
|
||||
conn,
|
||||
"conversation.deleted",
|
||||
actor=user_id,
|
||||
conversation_id=str(conv["id"]),
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error deleting conversation: {err}", exc_info=True
|
||||
@@ -65,7 +72,16 @@ class DeleteAllConversations(Resource):
|
||||
user_id = decoded_token.get("sub")
|
||||
try:
|
||||
with db_session() as conn:
|
||||
ConversationsRepository(conn).delete_all_for_user(user_id)
|
||||
deleted = ConversationsRepository(conn).delete_all_for_user(user_id)
|
||||
# Nothing deleted is not an event; the endpoint is idempotent
|
||||
# and a row here would render as a destructive action.
|
||||
if deleted:
|
||||
record_event(
|
||||
conn,
|
||||
"conversation.deleted_all",
|
||||
actor=user_id,
|
||||
deleted=deleted,
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error deleting all conversations: {err}", exc_info=True
|
||||
|
||||
@@ -10,6 +10,7 @@ from pydantic import ValidationError
|
||||
|
||||
from docsgpt.agents.tools.path_utils import validate_tool_path
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.audit import record_event
|
||||
from docsgpt.api.pat.rules import filter_listing
|
||||
from docsgpt.api.user.tasks import (
|
||||
convert_source_to_wiki,
|
||||
@@ -280,6 +281,13 @@ class DeleteOldIndexes(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
SourcesRepository(conn).delete(resolved_id, user)
|
||||
record_event(
|
||||
conn,
|
||||
"source.deleted",
|
||||
actor=user,
|
||||
source_id=resolved_id,
|
||||
name=doc.get("name"),
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error deleting source row: {err}", exc_info=True
|
||||
@@ -500,6 +508,23 @@ class ReingestSource(Resource):
|
||||
exc_info=True,
|
||||
)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
record_event(
|
||||
conn,
|
||||
"source.reingested",
|
||||
actor=user,
|
||||
source_id=resolved_source_id,
|
||||
name=doc.get("name"),
|
||||
# A team editor can reingest a source they do not own.
|
||||
owner=owner if owner != user else None,
|
||||
task_id=task.id,
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.warning(
|
||||
f"Could not audit reingest for {resolved_source_id}: {err}",
|
||||
exc_info=True,
|
||||
)
|
||||
return make_response(jsonify({"success": True, "task_id": task.id}), 200)
|
||||
|
||||
|
||||
@@ -811,6 +836,14 @@ class CreateWikiSource(Resource):
|
||||
updated_via="human",
|
||||
)
|
||||
rebuild_wiki_directory_structure(conn, source_id, user)
|
||||
record_event(
|
||||
conn,
|
||||
"source.created",
|
||||
actor=user,
|
||||
source_id=source_id,
|
||||
name=name,
|
||||
type="wiki",
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error creating wiki source: {err}", exc_info=True
|
||||
|
||||
@@ -5,12 +5,14 @@ import os
|
||||
import tempfile
|
||||
import uuid
|
||||
import zipfile
|
||||
from typing import Optional
|
||||
|
||||
from flask import current_app, jsonify, make_response, request
|
||||
from flask_restx import fields, Namespace, Resource
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.audit import record_event
|
||||
from docsgpt.api.user.tasks import ingest, ingest_connector_task, ingest_remote
|
||||
from docsgpt.api.user.team_sharing import effective_write_owner
|
||||
from docsgpt.core.settings import settings
|
||||
@@ -178,6 +180,40 @@ def _source_archive_limits() -> ZipExtractionLimits:
|
||||
)
|
||||
|
||||
|
||||
def _audit_source_created(
|
||||
*,
|
||||
source_id: str,
|
||||
user: Optional[str],
|
||||
name: Optional[str],
|
||||
source_type: str,
|
||||
task_id: Optional[str],
|
||||
) -> None:
|
||||
"""Record ``source.created`` for an accepted ingestion job.
|
||||
|
||||
Audited at the request, not in the worker: enqueuing is the user's action.
|
||||
The ingest may still fail, which the source's own status records.
|
||||
|
||||
Every path that mints a ``source_id`` and dispatches must call this --
|
||||
``source.deleted`` is recorded for remote sources too, and a trail showing
|
||||
a deletion with no matching creation is worse than no trail.
|
||||
"""
|
||||
try:
|
||||
with db_session() as conn:
|
||||
record_event(
|
||||
conn,
|
||||
"source.created",
|
||||
actor=user,
|
||||
source_id=source_id,
|
||||
name=name,
|
||||
type=source_type,
|
||||
task_id=task_id,
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.warning(
|
||||
"Could not audit source.created for %s: %s", source_id, err, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
@sources_upload_ns.route("/upload")
|
||||
class UploadFile(Resource):
|
||||
@api.expect(
|
||||
@@ -396,6 +432,13 @@ class UploadFile(Resource):
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
# Predetermined id matches the dedup-claim row; loser GET sees same.
|
||||
response_task_id = predetermined_task_id or task.id
|
||||
_audit_source_created(
|
||||
source_id=str(source_uuid),
|
||||
user=user,
|
||||
name=job_name,
|
||||
source_type="local",
|
||||
task_id=response_task_id,
|
||||
)
|
||||
# ``source_uuid`` was minted above and passed to the worker as
|
||||
# ``source_id``; the worker uses it verbatim for every SSE event,
|
||||
# so the frontend can correlate inbound ``source.ingest.*`` to
|
||||
@@ -547,6 +590,13 @@ class UploadRemote(Resource):
|
||||
connector_kwargs["task_id"] = predetermined_task_id
|
||||
task = ingest_connector_task.apply_async(**connector_kwargs)
|
||||
response_task_id = predetermined_task_id or task.id
|
||||
_audit_source_created(
|
||||
source_id=str(source_uuid),
|
||||
user=user,
|
||||
name=data["name"],
|
||||
source_type=data["source"],
|
||||
task_id=response_task_id,
|
||||
)
|
||||
# ``source_uuid`` was minted above and passed to the
|
||||
# worker as ``source_id``; the worker uses it verbatim
|
||||
# for every SSE event, so the frontend can correlate
|
||||
@@ -580,6 +630,13 @@ class UploadRemote(Resource):
|
||||
_release_claim(scoped_key)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
response_task_id = predetermined_task_id or task.id
|
||||
_audit_source_created(
|
||||
source_id=str(source_uuid),
|
||||
user=user,
|
||||
name=data["name"],
|
||||
source_type=data["source"],
|
||||
task_id=response_task_id,
|
||||
)
|
||||
response_payload = {
|
||||
"success": True,
|
||||
"task_id": response_task_id,
|
||||
|
||||
@@ -62,13 +62,26 @@ def _current_user() -> str | None:
|
||||
return token.get("sub") if isinstance(token, dict) else None
|
||||
|
||||
|
||||
# Metadata keys naming the user a team event acted on, most specific first.
|
||||
# Lets ``_audit`` fill ``target_id`` without every call site repeating it.
|
||||
_TARGET_METADATA_KEYS = ("target_user", "target_user_id", "new_owner")
|
||||
|
||||
|
||||
def _audit(conn, actor: str | None, event: str, **metadata) -> None:
|
||||
"""Append a team management event to the audit trail (best-effort).
|
||||
|
||||
Runs inside the action's transaction so the audit row commits atomically
|
||||
with the change. Never raises into the request path — an audit failure must
|
||||
not fail the operation.
|
||||
|
||||
Team events are filed under the acting user. When the action names another
|
||||
member (add, role change, removal, ownership transfer) that member becomes
|
||||
the row's ``target_id``; otherwise the event has no user target.
|
||||
"""
|
||||
detail = {k: v for k, v in metadata.items() if v is not None}
|
||||
target = next(
|
||||
(detail[key] for key in _TARGET_METADATA_KEYS if detail.get(key)), None
|
||||
)
|
||||
try:
|
||||
# SAVEPOINT: a failed audit insert poisons the surrounding txn, so
|
||||
# nest it — on failure only the audit rolls back, not the action.
|
||||
@@ -78,7 +91,9 @@ def _audit(conn, actor: str | None, event: str, **metadata) -> None:
|
||||
event=event,
|
||||
ip=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
metadata={k: v for k, v in metadata.items() if v is not None},
|
||||
metadata=detail,
|
||||
actor_id=actor or "unknown",
|
||||
target_id=target,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("team audit insert failed for event=%s", event, exc_info=True)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Audit event taxonomy: the categories the admin activity feed filters on.
|
||||
|
||||
Lives at the top level (like ``docsgpt/pricing.py``) because both the API
|
||||
layer — which records events — and the storage layer — which has to filter a
|
||||
merged feed in SQL — need the same map, and neither should import the other.
|
||||
|
||||
Two representations of one table:
|
||||
|
||||
* :func:`category_for` classifies a single event name in Python.
|
||||
* :func:`category_case_sql` emits the equivalent ``CASE`` expression so a
|
||||
category filter can be pushed into the query instead of enumerating every
|
||||
event name a release might have written.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Categories in the order the UI shows them. ``other`` catches event names an
|
||||
# older release wrote that this one does not know about — the feed must never
|
||||
# hide a row just because its name is unfamiliar.
|
||||
ACTIVITY_CATEGORIES = ("identity", "access", "config", "data", "device", "safety", "other")
|
||||
|
||||
# Exact event names.
|
||||
_EXACT: dict[str, str] = {
|
||||
# Who you are: authentication, provisioning, personal credentials.
|
||||
"oidc_login": "identity",
|
||||
"oidc_login_denied": "identity",
|
||||
"oidc_refresh": "identity",
|
||||
"backchannel_logout": "identity",
|
||||
"scim_created": "identity",
|
||||
"scim_deactivated": "identity",
|
||||
"scim_reactivated": "identity",
|
||||
"pat_created": "identity",
|
||||
"pat_revoked": "identity",
|
||||
"pat_regenerated": "identity",
|
||||
# What you may do: roles, account state.
|
||||
"role_granted": "access",
|
||||
"role_revoked": "access",
|
||||
"admin_user_activated": "access",
|
||||
"admin_user_deactivated": "access",
|
||||
"admin_sessions_revoked": "access",
|
||||
# How the instance is configured.
|
||||
"quota_policy_set": "config",
|
||||
"quota_policy_deleted": "config",
|
||||
}
|
||||
|
||||
# Dotted namespaces, matched on the part before the first ``.``.
|
||||
_NAMESPACES: dict[str, str] = {
|
||||
"team": "access",
|
||||
"source": "data",
|
||||
"agent": "data",
|
||||
"conversation": "data",
|
||||
"device": "device",
|
||||
"guardrail": "safety",
|
||||
}
|
||||
|
||||
|
||||
def category_for(event: str) -> str:
|
||||
"""Return the activity category for ``event``.
|
||||
|
||||
Args:
|
||||
event: The audit event name (``oidc_login``, ``source.deleted``).
|
||||
|
||||
Returns:
|
||||
One of :data:`ACTIVITY_CATEGORIES`; ``"other"`` for names this release
|
||||
does not recognise.
|
||||
"""
|
||||
namespace, dot, _ = event.partition(".")
|
||||
if dot:
|
||||
return _NAMESPACES.get(namespace, "other")
|
||||
return _EXACT.get(event, "other")
|
||||
|
||||
|
||||
def category_case_sql(column: str) -> str:
|
||||
"""Build the SQL ``CASE`` that classifies ``column`` the way :func:`category_for` does.
|
||||
|
||||
Uses ``split_part`` rather than ``LIKE`` so the expression carries no ``%``
|
||||
— which a pyformat DBAPI would otherwise read as a parameter marker.
|
||||
|
||||
Args:
|
||||
column: The SQL expression holding an event name.
|
||||
|
||||
Returns:
|
||||
A parenthesised ``CASE`` expression. Every literal in it comes from the
|
||||
maps above, never from user input, so it is safe to interpolate.
|
||||
"""
|
||||
namespace_branches = " ".join(
|
||||
f"WHEN '{namespace}' THEN '{category}'"
|
||||
for namespace, category in _NAMESPACES.items()
|
||||
)
|
||||
by_category: dict[str, list[str]] = {}
|
||||
for event, category in _EXACT.items():
|
||||
by_category.setdefault(category, []).append(event)
|
||||
exact_branches = " ".join(
|
||||
f"WHEN {column} IN ({', '.join(repr(event) for event in sorted(events))}) "
|
||||
f"THEN '{category}'"
|
||||
for category, events in sorted(by_category.items())
|
||||
)
|
||||
return (
|
||||
"(CASE "
|
||||
f"WHEN position('.' in {column}) > 0 "
|
||||
f"THEN (CASE split_part({column}, '.', 1) {namespace_branches} ELSE 'other' END) "
|
||||
f"{exact_branches} "
|
||||
"ELSE 'other' END)"
|
||||
)
|
||||
@@ -65,3 +65,21 @@ def row_to_dict(row: Any) -> dict:
|
||||
out["_id"] = out["id"]
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def like_escape(term: str) -> str:
|
||||
"""Escape LIKE/ILIKE metacharacters so ``term`` matches literally.
|
||||
|
||||
A search box takes a substring, not a pattern. Interpolated raw, ``%``
|
||||
matches everything and ``_`` matches any single character, so searching
|
||||
for ``100%`` or ``q1_report`` silently returns the wrong rows.
|
||||
|
||||
Callers must pair this with ``ESCAPE '\\'`` on the comparison.
|
||||
|
||||
Args:
|
||||
term: The user-supplied substring.
|
||||
|
||||
Returns:
|
||||
``term`` with ``\\``, ``%`` and ``_`` backslash-escaped.
|
||||
"""
|
||||
return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
@@ -68,6 +68,11 @@ auth_events_table = Table(
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
# Who performed the action, and (when the action is about a user) whom it
|
||||
# was performed on. ``user_id`` predates both and is kept as the per-user
|
||||
# feed key; see migration 0034.
|
||||
Column("actor_id", Text, nullable=False),
|
||||
Column("target_id", Text),
|
||||
Column("event", Text, nullable=False),
|
||||
Column("ip", Text),
|
||||
Column("user_agent", Text),
|
||||
@@ -245,6 +250,13 @@ token_usage_table = Table(
|
||||
# Added in ``0033_quotas``. USD cost of the call at write time; 0 for
|
||||
# unpriced and bring-your-own models.
|
||||
Column("cost", Numeric(12, 8), nullable=False, server_default="0"),
|
||||
# Added in ``0035_token_usage_latency``. Time spent waiting on the
|
||||
# provider (a stream excludes consumer backpressure; see ``usage.py``),
|
||||
# and time to the first streamed chunk. ``ttft_ms`` is NULL for
|
||||
# non-streaming calls and for streams that failed before yielding --
|
||||
# "no first token", not 0.
|
||||
Column("duration_ms", Integer),
|
||||
Column("ttft_ms", Integer),
|
||||
)
|
||||
|
||||
user_logs_table = Table(
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""One merged, normalized feed over the instance's three audit journals.
|
||||
|
||||
DocsGPT keeps three append-only journals, each written by a different part of
|
||||
the system and each previously readable only on its own terms:
|
||||
|
||||
* ``auth_events`` — identity, access, configuration and (since the data-plane
|
||||
hooks) resource mutations.
|
||||
* ``device_audit_log`` — every remote-device command dispatch and its outcome.
|
||||
* ``guardrail_events`` — every guardrail decision on a request.
|
||||
|
||||
An operator reviewing an incident wants one timeline, not three. This
|
||||
repository projects all three onto a common row shape and merges them with
|
||||
``UNION ALL``, so a single ordered, filtered, paginated feed spans the lot.
|
||||
|
||||
Two exclusions are deliberate and mirror the per-agent guardrail view: the
|
||||
``guardrail_events.api_key`` column is a raw agent key, and ``matched_value``
|
||||
is unredacted source text. Neither is projected here, admin-gated or not.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Iterator, Optional, Sequence
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from docsgpt.audit_events import category_case_sql
|
||||
from docsgpt.storage.db.base_repository import like_escape
|
||||
|
||||
|
||||
# Columns every branch of the union produces, in order.
|
||||
ACTIVITY_COLUMNS = (
|
||||
"feed",
|
||||
"id",
|
||||
"event",
|
||||
"category",
|
||||
"actor_id",
|
||||
"target_id",
|
||||
"ip",
|
||||
"user_agent",
|
||||
"outcome",
|
||||
"detail",
|
||||
"created_at",
|
||||
)
|
||||
|
||||
# Which journal each feed name reads from. Used to skip a branch entirely when
|
||||
# the category filter cannot match it.
|
||||
_FEED_CATEGORIES = {"auth": None, "device": "device", "guardrail": "safety"}
|
||||
|
||||
# ``feed`` and ``id`` break ties so a page boundary is deterministic and the
|
||||
# keyset cursor below has something total to compare against.
|
||||
_ORDER_BY = "ORDER BY created_at DESC, feed DESC, id DESC"
|
||||
|
||||
_AUTH_BRANCH = f"""
|
||||
SELECT 'auth' AS feed,
|
||||
id::text AS id,
|
||||
event,
|
||||
{category_case_sql("event")} AS category,
|
||||
actor_id,
|
||||
target_id,
|
||||
ip,
|
||||
user_agent,
|
||||
NULL::text AS outcome,
|
||||
metadata AS detail,
|
||||
created_at
|
||||
FROM auth_events
|
||||
"""
|
||||
|
||||
_DEVICE_BRANCH = """
|
||||
SELECT 'device' AS feed,
|
||||
id::text AS id,
|
||||
'device.' || action AS event,
|
||||
'device' AS category,
|
||||
user_id AS actor_id,
|
||||
NULL::text AS target_id,
|
||||
NULL::text AS ip,
|
||||
NULL::text AS user_agent,
|
||||
decision AS outcome,
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'device_id', device_id,
|
||||
'invocation_id', invocation_id,
|
||||
'command', command,
|
||||
'working_dir', working_dir,
|
||||
'approval_mode', approval_mode,
|
||||
'decision_reason', decision_reason,
|
||||
'exit_code', exit_code,
|
||||
'duration_ms', duration_ms,
|
||||
'agent_id', agent_id,
|
||||
'conversation_id', conversation_id
|
||||
)) AS detail,
|
||||
created_at
|
||||
FROM device_audit_log
|
||||
"""
|
||||
|
||||
_GUARDRAIL_BRANCH = """
|
||||
SELECT 'guardrail' AS feed,
|
||||
g.id::text AS id,
|
||||
'guardrail.' || g.stage AS event,
|
||||
'safety' AS category,
|
||||
g.user_id AS actor_id,
|
||||
NULL::text AS target_id,
|
||||
NULL::text AS ip,
|
||||
NULL::text AS user_agent,
|
||||
g.outcome,
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'check_name', g.check_name,
|
||||
'detector_type', g.detector_type,
|
||||
'action', g.action,
|
||||
'category', g.category,
|
||||
'score', g.score,
|
||||
'match_count', g.match_count,
|
||||
'agent_id', g.agent_id,
|
||||
'message_id', g.message_id,
|
||||
'request_id', g.request_id,
|
||||
'detail', g.detail
|
||||
)) AS detail,
|
||||
g.created_at
|
||||
FROM guardrail_events g
|
||||
"""
|
||||
|
||||
_BRANCHES = {
|
||||
"auth": _AUTH_BRANCH,
|
||||
"device": _DEVICE_BRANCH,
|
||||
"guardrail": _GUARDRAIL_BRANCH,
|
||||
}
|
||||
|
||||
|
||||
class ActivityRepository:
|
||||
"""Read-only merged feed over ``auth_events`` + the two side journals."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _feeds_for(
|
||||
feeds: Optional[Sequence[str]], categories: Optional[Sequence[str]]
|
||||
) -> list[str]:
|
||||
"""Which union branches can still produce a matching row.
|
||||
|
||||
A ``category=device`` filter cannot be satisfied by ``auth_events``, so
|
||||
that branch is dropped from the union rather than scanned and
|
||||
discarded. ``auth`` spans several categories and is only dropped when
|
||||
the filter names none of them.
|
||||
"""
|
||||
selected = list(feeds) if feeds else list(_BRANCHES)
|
||||
selected = [feed for feed in selected if feed in _BRANCHES]
|
||||
if not categories:
|
||||
return selected
|
||||
wanted = set(categories)
|
||||
fixed_only = {category for category in _FEED_CATEGORIES.values() if category}
|
||||
keep = []
|
||||
for feed in selected:
|
||||
fixed = _FEED_CATEGORIES[feed]
|
||||
if fixed is None:
|
||||
# ``auth`` spans several categories, so keep it unless the
|
||||
# filter names only categories another journal owns outright.
|
||||
if wanted - fixed_only:
|
||||
keep.append(feed)
|
||||
elif fixed in wanted:
|
||||
keep.append(feed)
|
||||
return keep
|
||||
|
||||
def _query(
|
||||
self,
|
||||
*,
|
||||
feeds: Optional[Sequence[str]] = None,
|
||||
categories: Optional[Sequence[str]] = None,
|
||||
events: Optional[Sequence[str]] = None,
|
||||
actor_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
since: Optional[datetime] = None,
|
||||
until: Optional[datetime] = None,
|
||||
search: Optional[str] = None,
|
||||
) -> tuple[str, dict]:
|
||||
selected = self._feeds_for(feeds, categories)
|
||||
if not selected:
|
||||
return "", {}
|
||||
union = " UNION ALL ".join(_BRANCHES[feed] for feed in selected)
|
||||
|
||||
clauses: list[str] = []
|
||||
params: dict[str, Any] = {}
|
||||
if categories:
|
||||
clauses.append("category = ANY(:categories)")
|
||||
params["categories"] = list(categories)
|
||||
if events:
|
||||
clauses.append("event = ANY(:events)")
|
||||
params["events"] = list(events)
|
||||
if actor_id:
|
||||
clauses.append("actor_id = :actor_id")
|
||||
params["actor_id"] = actor_id
|
||||
if user_id:
|
||||
clauses.append("(actor_id = :user_id OR target_id = :user_id)")
|
||||
params["user_id"] = user_id
|
||||
if since is not None:
|
||||
clauses.append("created_at >= :since")
|
||||
params["since"] = since
|
||||
if until is not None:
|
||||
clauses.append("created_at <= :until")
|
||||
params["until"] = until
|
||||
if search:
|
||||
# ``search`` is a literal substring, not a pattern: an operator
|
||||
# looking for "100%" or "q1_report" must not have those characters
|
||||
# read as wildcards.
|
||||
clauses.append(
|
||||
"(actor_id ILIKE :search ESCAPE '\\' "
|
||||
"OR target_id ILIKE :search ESCAPE '\\' "
|
||||
"OR event ILIKE :search ESCAPE '\\' "
|
||||
"OR ip ILIKE :search ESCAPE '\\' "
|
||||
"OR COALESCE(outcome, '') ILIKE :search ESCAPE '\\' "
|
||||
"OR detail::text ILIKE :search ESCAPE '\\')"
|
||||
)
|
||||
params["search"] = f"%{like_escape(search)}%"
|
||||
# Always a WHERE, even with no filters, so the keyset cursor below can
|
||||
# append " AND ..." without sniffing the string it was handed.
|
||||
where = "WHERE " + " AND ".join(clauses or ["TRUE"])
|
||||
return f"SELECT * FROM ({union}) AS activity {where}", params
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
feeds: Optional[Sequence[str]] = None,
|
||||
categories: Optional[Sequence[str]] = None,
|
||||
events: Optional[Sequence[str]] = None,
|
||||
actor_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
since: Optional[datetime] = None,
|
||||
until: Optional[datetime] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Return one page of the merged feed, newest first.
|
||||
|
||||
Args:
|
||||
feeds: Restrict to named journals (``auth``, ``device``, ``guardrail``).
|
||||
categories: Restrict to activity categories.
|
||||
events: Restrict to exact event names.
|
||||
actor_id: Only rows this user performed.
|
||||
user_id: Rows where this user is the actor or the target.
|
||||
since: Inclusive lower bound on ``created_at``.
|
||||
until: Inclusive upper bound on ``created_at``.
|
||||
search: Case-insensitive substring over ids, event, IP and detail.
|
||||
limit: Page size.
|
||||
offset: Rows to skip.
|
||||
|
||||
Returns:
|
||||
Row dicts carrying :data:`ACTIVITY_COLUMNS`.
|
||||
"""
|
||||
sql, params = self._query(
|
||||
feeds=feeds,
|
||||
categories=categories,
|
||||
events=events,
|
||||
actor_id=actor_id,
|
||||
user_id=user_id,
|
||||
since=since,
|
||||
until=until,
|
||||
search=search,
|
||||
)
|
||||
if not sql:
|
||||
return []
|
||||
params.update({"limit": int(limit), "offset": int(offset)})
|
||||
result = self._conn.execute(
|
||||
text(f"{sql} {_ORDER_BY} LIMIT :limit OFFSET :offset"),
|
||||
params,
|
||||
)
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
def _page_after(
|
||||
self,
|
||||
cursor: Optional[tuple[datetime, str, str]],
|
||||
limit: int,
|
||||
filters: dict,
|
||||
) -> list[dict]:
|
||||
"""One keyset page strictly older than ``cursor``.
|
||||
|
||||
OFFSET paging would drift here: the feed is ordered newest-first and
|
||||
the journals are append-only, so rows inserted mid-export push the
|
||||
window down and repeat rows that were already emitted.
|
||||
"""
|
||||
sql, params = self._query(**filters)
|
||||
if not sql:
|
||||
return []
|
||||
if cursor is not None:
|
||||
sql += " AND (created_at, feed, id) < (:cur_at, :cur_feed, :cur_id)"
|
||||
params.update(
|
||||
{"cur_at": cursor[0], "cur_feed": cursor[1], "cur_id": cursor[2]}
|
||||
)
|
||||
params["limit"] = int(limit)
|
||||
result = self._conn.execute(text(f"{sql} {_ORDER_BY} LIMIT :limit"), params)
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
def count(
|
||||
self,
|
||||
*,
|
||||
feeds: Optional[Sequence[str]] = None,
|
||||
categories: Optional[Sequence[str]] = None,
|
||||
events: Optional[Sequence[str]] = None,
|
||||
actor_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
since: Optional[datetime] = None,
|
||||
until: Optional[datetime] = None,
|
||||
search: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Total rows matching the same filters as :meth:`list`."""
|
||||
sql, params = self._query(
|
||||
feeds=feeds,
|
||||
categories=categories,
|
||||
events=events,
|
||||
actor_id=actor_id,
|
||||
user_id=user_id,
|
||||
since=since,
|
||||
until=until,
|
||||
search=search,
|
||||
)
|
||||
if not sql:
|
||||
return 0
|
||||
scalar = self._conn.execute(
|
||||
text(f"SELECT count(*) FROM ({sql}) AS matched"), params
|
||||
).scalar()
|
||||
return int(scalar or 0)
|
||||
|
||||
def iter_all(
|
||||
self,
|
||||
*,
|
||||
chunk_size: int = 1000,
|
||||
max_rows: int = 100_000,
|
||||
**filters: Any,
|
||||
) -> Iterator[dict]:
|
||||
"""Yield every matching row, newest first, for the export endpoint.
|
||||
|
||||
Pages by keyset, not offset: the journals are append-only and the feed
|
||||
is newest-first, so rows written mid-export would otherwise shift the
|
||||
window and repeat rows already emitted.
|
||||
|
||||
Args:
|
||||
chunk_size: Rows fetched per round trip.
|
||||
max_rows: Hard ceiling; an export stops here rather than running
|
||||
unbounded against a busy instance.
|
||||
**filters: As :meth:`list`, minus ``limit`` / ``offset``.
|
||||
|
||||
Yields:
|
||||
Row dicts carrying :data:`ACTIVITY_COLUMNS`.
|
||||
"""
|
||||
emitted = 0
|
||||
cursor: Optional[tuple[datetime, str, str]] = None
|
||||
while emitted < max_rows:
|
||||
page = self._page_after(
|
||||
cursor, min(chunk_size, max_rows - emitted), filters
|
||||
)
|
||||
if not page:
|
||||
return
|
||||
for row in page:
|
||||
yield row
|
||||
last = page[-1]
|
||||
cursor = (last["created_at"], last["feed"], last["id"])
|
||||
emitted += len(page)
|
||||
|
||||
def event_names(self) -> list[dict]:
|
||||
"""Distinct ``(event, category)`` pairs across all three journals.
|
||||
|
||||
Feeds the filter's event picker, so operators choose from what the
|
||||
instance actually recorded rather than typing a name from memory.
|
||||
"""
|
||||
union = " UNION ALL ".join(_BRANCHES.values())
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
f"SELECT DISTINCT event, category FROM ({union}) AS activity "
|
||||
"ORDER BY category, event"
|
||||
)
|
||||
)
|
||||
return [{"event": row[0], "category": row[1]} for row in result.fetchall()]
|
||||
@@ -14,6 +14,12 @@ from typing import Optional
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from docsgpt.storage.db.base_repository import row_to_dict
|
||||
from docsgpt.storage.db.repositories.token_usage import TokenUsageRepository
|
||||
|
||||
|
||||
def _round_ms(value: Optional[float]) -> Optional[int]:
|
||||
"""Round a percentile to whole milliseconds, preserving NULL as None."""
|
||||
return int(round(float(value))) if value is not None else None
|
||||
|
||||
|
||||
class AdminStatsRepository:
|
||||
@@ -66,21 +72,146 @@ class AdminStatsRepository:
|
||||
}
|
||||
|
||||
def top_token_users(self, *, since: datetime, limit: int = 10) -> list[dict]:
|
||||
"""Highest token consumers since ``since`` (admin usage view)."""
|
||||
"""Highest consumers since ``since``, with spend (admin usage view).
|
||||
|
||||
Ordered by tokens, not cost: a cheap model can dominate token volume
|
||||
while a costly one dominates the bill, and the operator wants to see
|
||||
both columns rather than have the ranking pick for them.
|
||||
|
||||
Run-level rollup rows are excluded, matching every other spend query:
|
||||
a scheduled run already has a row per LLM call, so counting the rollup
|
||||
too would bill it twice.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT user_id, SUM(prompt_tokens + generated_tokens) AS tokens
|
||||
SELECT user_id,
|
||||
SUM(prompt_tokens + generated_tokens) AS tokens,
|
||||
COALESCE(SUM(cost), 0) AS cost
|
||||
FROM token_usage
|
||||
WHERE timestamp >= :since AND user_id IS NOT NULL
|
||||
AND source <> ALL(:rollup_sources)
|
||||
GROUP BY user_id
|
||||
ORDER BY tokens DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
),
|
||||
{"since": since, "limit": int(limit)},
|
||||
{
|
||||
"since": since,
|
||||
"limit": int(limit),
|
||||
"rollup_sources": list(TokenUsageRepository.ROLLUP_SOURCES),
|
||||
},
|
||||
)
|
||||
return [{"user_id": r[0], "tokens": int(r[1])} for r in result.fetchall()]
|
||||
return [
|
||||
{"user_id": r[0], "tokens": int(r[1]), "cost": float(r[2])}
|
||||
for r in result.fetchall()
|
||||
]
|
||||
|
||||
def latency_summary(self, *, since: datetime) -> dict:
|
||||
"""Call-latency percentiles since ``since``.
|
||||
|
||||
Rows written before the latency columns existed, and streams that
|
||||
never yielded, hold NULL; percentile_cont skips them, so the summary
|
||||
describes the calls that were actually measured. ``samples`` says how
|
||||
many those were, so a percentile from three calls is not read as a
|
||||
service-level figure.
|
||||
"""
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT count(duration_ms) AS samples,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms)
|
||||
AS p50_ms,
|
||||
percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms)
|
||||
AS p95_ms,
|
||||
count(ttft_ms) AS ttft_samples,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY ttft_ms)
|
||||
AS ttft_p50_ms
|
||||
FROM token_usage
|
||||
WHERE timestamp >= :since
|
||||
"""
|
||||
),
|
||||
{"since": since},
|
||||
).one()
|
||||
return {
|
||||
"samples": int(row.samples or 0),
|
||||
"p50_ms": _round_ms(row.p50_ms),
|
||||
"p95_ms": _round_ms(row.p95_ms),
|
||||
"ttft_samples": int(row.ttft_samples or 0),
|
||||
"ttft_p50_ms": _round_ms(row.ttft_p50_ms),
|
||||
}
|
||||
|
||||
def user_usage_breakdown(
|
||||
self, user_id: str, *, since: datetime, limit: int = 10
|
||||
) -> dict:
|
||||
"""Per-user spend detail: totals, and a split by model and by flow.
|
||||
|
||||
The admin user drill-down showed a single ``tokens_30d`` number, which
|
||||
cannot answer either question an operator actually has -- what is this
|
||||
person costing, and what is driving it.
|
||||
|
||||
Run-level rollup rows are excluded, so a scheduled run is counted once
|
||||
(by its per-call rows), not twice.
|
||||
|
||||
Args:
|
||||
user_id: The billable user (auth ``sub``).
|
||||
since: Inclusive window start.
|
||||
limit: How many models / flows to return, busiest first.
|
||||
|
||||
Returns:
|
||||
``{"totals": {...}, "by_model": [...], "by_source": [...]}``.
|
||||
"""
|
||||
rollups = list(TokenUsageRepository.ROLLUP_SOURCES)
|
||||
totals = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COALESCE(SUM(prompt_tokens + generated_tokens), 0) AS tokens,
|
||||
COALESCE(SUM(cost), 0) AS cost,
|
||||
count(*) AS calls
|
||||
FROM token_usage
|
||||
WHERE user_id = :u AND timestamp >= :since
|
||||
AND source <> ALL(:rollup_sources)
|
||||
"""
|
||||
),
|
||||
{"u": user_id, "since": since, "rollup_sources": rollups},
|
||||
).one()
|
||||
|
||||
def _breakdown(key_expr: str) -> list[dict]:
|
||||
rows = self._conn.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT {key_expr} AS key,
|
||||
COALESCE(SUM(prompt_tokens + generated_tokens), 0) AS tokens,
|
||||
COALESCE(SUM(cost), 0) AS cost
|
||||
FROM token_usage
|
||||
WHERE user_id = :u AND timestamp >= :since
|
||||
AND source <> ALL(:rollup_sources)
|
||||
GROUP BY key
|
||||
ORDER BY tokens DESC, key
|
||||
LIMIT :limit
|
||||
"""
|
||||
),
|
||||
{
|
||||
"u": user_id,
|
||||
"since": since,
|
||||
"limit": int(limit),
|
||||
"rollup_sources": rollups,
|
||||
},
|
||||
)
|
||||
return [
|
||||
{"key": r.key, "tokens": int(r.tokens), "cost": float(r.cost)}
|
||||
for r in rows.fetchall()
|
||||
]
|
||||
|
||||
return {
|
||||
"totals": {
|
||||
"tokens": int(totals.tokens),
|
||||
"cost": float(totals.cost),
|
||||
"calls": int(totals.calls),
|
||||
},
|
||||
"by_model": _breakdown("COALESCE(model_id, 'unknown')"),
|
||||
"by_source": _breakdown("COALESCE(source, 'agent_stream')"),
|
||||
}
|
||||
|
||||
def list_users(
|
||||
self, user_id_filter: Optional[str], offset: int, limit: int
|
||||
|
||||
@@ -3,15 +3,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
from typing import Any, Optional, Sequence
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from docsgpt.storage.db.base_repository import row_to_dict
|
||||
|
||||
|
||||
# Actors that are not a person. Used where an automated system performs the
|
||||
# action on a user's behalf, so the feed never reads as if the user did it.
|
||||
SYSTEM_ACTOR_SCIM = "system:scim"
|
||||
SYSTEM_ACTOR_OIDC = "system:oidc"
|
||||
|
||||
|
||||
class AuthEventsRepository:
|
||||
"""Append-only audit trail of login / logout / provisioning events."""
|
||||
"""Append-only audit trail of identity, access and data-plane events.
|
||||
|
||||
Every row carries three attribution columns:
|
||||
|
||||
* ``actor_id`` — who performed the action. Never NULL.
|
||||
* ``target_id`` — the user the action was performed on, or NULL when the
|
||||
event is not about a user (a team change, an instance-wide policy).
|
||||
* ``user_id`` — the legacy subject column, kept as the key for the
|
||||
per-user feed (:meth:`list_recent`). Defaults to the target, falling
|
||||
back to the actor for actor-only events.
|
||||
|
||||
See migration ``0034_auth_events_actor_target``.
|
||||
"""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
@@ -23,18 +41,45 @@ class AuthEventsRepository:
|
||||
ip: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
*,
|
||||
actor_id: Optional[str] = None,
|
||||
target_id: Optional[str] = "",
|
||||
) -> dict:
|
||||
"""Record one auth event and return the inserted row."""
|
||||
"""Record one audit event and return the inserted row.
|
||||
|
||||
Args:
|
||||
user_id: The row's subject, kept for the per-user feed.
|
||||
event: Event name (``oidc_login``, ``source.deleted``, ...).
|
||||
ip: Client IP, when the event came from a request.
|
||||
user_agent: Client ``User-Agent``, when the event came from a request.
|
||||
metadata: Free-form JSON detail shown in the audit drill-down.
|
||||
actor_id: Who performed the action. Defaults to ``user_id``, which
|
||||
is correct for self-service events (login, PAT create).
|
||||
target_id: The user acted upon. Defaults to ``user_id``; pass
|
||||
``None`` explicitly for events with no user target.
|
||||
|
||||
Returns:
|
||||
The inserted row as a dict.
|
||||
"""
|
||||
# Sentinel default: ``None`` is a meaningful value here ("no target"),
|
||||
# so it cannot double as "caller did not say".
|
||||
resolved_target = user_id if target_id == "" else target_id
|
||||
resolved_actor = actor_id or user_id
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO auth_events (user_id, event, ip, user_agent, metadata)
|
||||
VALUES (:user_id, :event, :ip, :user_agent, CAST(:metadata AS jsonb))
|
||||
INSERT INTO auth_events
|
||||
(user_id, actor_id, target_id, event, ip, user_agent, metadata)
|
||||
VALUES
|
||||
(:user_id, :actor_id, :target_id, :event, :ip, :user_agent,
|
||||
CAST(:metadata AS jsonb))
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"actor_id": resolved_actor,
|
||||
"target_id": resolved_target,
|
||||
"event": event,
|
||||
"ip": ip,
|
||||
"user_agent": user_agent,
|
||||
@@ -43,30 +88,60 @@ class AuthEventsRepository:
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def list_recent(self, user_id: str, limit: int = 50) -> list[dict]:
|
||||
"""Return the newest events for ``user_id``, newest first."""
|
||||
def list_recent(
|
||||
self,
|
||||
user_id: str,
|
||||
limit: int = 50,
|
||||
*,
|
||||
exclude_events_like: Optional[Sequence[str]] = None,
|
||||
) -> list[dict]:
|
||||
"""Return the newest events for ``user_id``, newest first.
|
||||
|
||||
Args:
|
||||
user_id: The subject whose trail to read.
|
||||
limit: How many rows to return.
|
||||
exclude_events_like: Event-name prefixes to drop, e.g.
|
||||
``("source.", "agent.")``. Data-plane events are filed under
|
||||
the actor, so on an active account they would otherwise push
|
||||
a denied login or a role grant out of a short window within
|
||||
minutes.
|
||||
|
||||
Returns:
|
||||
Matching rows, newest first.
|
||||
"""
|
||||
clauses = ["user_id = :user_id"]
|
||||
params: dict = {"user_id": user_id, "limit": limit}
|
||||
for index, prefix in enumerate(exclude_events_like or ()):
|
||||
key = f"excl_{index}"
|
||||
clauses.append(f"event NOT LIKE :{key}")
|
||||
params[key] = f"{prefix}%"
|
||||
where = " AND ".join(clauses)
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
f"""
|
||||
SELECT * FROM auth_events
|
||||
WHERE user_id = :user_id
|
||||
WHERE {where}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "limit": limit},
|
||||
params,
|
||||
)
|
||||
return [row_to_dict(row) for row in result.fetchall()]
|
||||
|
||||
@staticmethod
|
||||
def _filter_clauses(event, user_id, since) -> tuple[str, dict]:
|
||||
def _filter_clauses(
|
||||
event: Optional[str],
|
||||
user_id: Optional[str],
|
||||
since: Any,
|
||||
) -> tuple[str, dict]:
|
||||
clauses: list[str] = []
|
||||
params: dict = {}
|
||||
if event:
|
||||
clauses.append("event = :event")
|
||||
params["event"] = event
|
||||
if user_id:
|
||||
clauses.append("user_id = :user_id")
|
||||
clauses.append("(user_id = :user_id OR target_id = :user_id)")
|
||||
params["user_id"] = user_id
|
||||
if since is not None:
|
||||
clauses.append("created_at >= :since")
|
||||
@@ -83,7 +158,12 @@ class AuthEventsRepository:
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Global audit feed (admin), newest first; optional event/user/since filters."""
|
||||
"""Single-journal audit feed for ``GET /api/admin/audit``, newest first.
|
||||
|
||||
The admin UI reads the merged feed instead; richer filtering lives on
|
||||
:class:`~docsgpt.storage.db.repositories.activity.ActivityRepository`
|
||||
rather than being implemented twice here.
|
||||
"""
|
||||
where, params = self._filter_clauses(event, user_id, since)
|
||||
params.update({"limit": int(limit), "offset": int(offset)})
|
||||
result = self._conn.execute(
|
||||
@@ -96,7 +176,11 @@ class AuthEventsRepository:
|
||||
return [row_to_dict(row) for row in result.fetchall()]
|
||||
|
||||
def count_all(
|
||||
self, *, event: Optional[str] = None, user_id: Optional[str] = None, since=None
|
||||
self,
|
||||
*,
|
||||
event: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
since=None,
|
||||
) -> int:
|
||||
"""Total matching the same filters as :meth:`list_all` (for pagination)."""
|
||||
where, params = self._filter_clauses(event, user_id, since)
|
||||
|
||||
@@ -38,6 +38,8 @@ class TokenUsageRepository:
|
||||
cached_tokens: Optional[int] = None,
|
||||
cache_write_tokens: Optional[int] = None,
|
||||
cost: float = 0.0,
|
||||
duration_ms: Optional[int] = None,
|
||||
ttft_ms: Optional[int] = None,
|
||||
) -> None:
|
||||
# Attribution guard: the ``token_usage_attribution_chk`` CHECK
|
||||
# constraint requires at least one of ``user_id`` / ``api_key``
|
||||
@@ -64,6 +66,7 @@ class TokenUsageRepository:
|
||||
user_id, api_key, agent_id,
|
||||
prompt_tokens, generated_tokens,
|
||||
cached_tokens, cache_write_tokens, cost,
|
||||
duration_ms, ttft_ms,
|
||||
source, request_id, model_id, timestamp
|
||||
)
|
||||
VALUES (
|
||||
@@ -71,6 +74,7 @@ class TokenUsageRepository:
|
||||
CAST(:agent_id AS uuid),
|
||||
:prompt_tokens, :generated_tokens,
|
||||
:cached_tokens, :cache_write_tokens, :cost,
|
||||
:duration_ms, :ttft_ms,
|
||||
:source, :request_id, :model_id, COALESCE(:timestamp, now())
|
||||
)
|
||||
"""
|
||||
@@ -84,6 +88,8 @@ class TokenUsageRepository:
|
||||
"cached_tokens": cached_tokens,
|
||||
"cache_write_tokens": cache_write_tokens,
|
||||
"cost": cost,
|
||||
"duration_ms": duration_ms,
|
||||
"ttft_ms": ttft_ms,
|
||||
"source": source,
|
||||
"request_id": request_id,
|
||||
"model_id": model_id,
|
||||
@@ -296,7 +302,22 @@ class TokenUsageRepository:
|
||||
f"""
|
||||
SELECT to_char(tu.timestamp AT TIME ZONE 'UTC', :fmt) AS bucket,
|
||||
COALESCE(SUM(tu.prompt_tokens), 0) AS prompt_tokens,
|
||||
COALESCE(SUM(tu.generated_tokens), 0) AS generated_tokens
|
||||
COALESCE(SUM(tu.generated_tokens), 0) AS generated_tokens,
|
||||
COALESCE(SUM(tu.cost), 0) AS cost,
|
||||
-- NULL cache bins mean "provider did not report", so
|
||||
-- they stay out of the sum rather than reading as 0.
|
||||
SUM(tu.cached_tokens) AS cached_tokens,
|
||||
-- The matching denominator: prompt tokens from the
|
||||
-- rows that reported. Dividing ``cached_tokens`` by
|
||||
-- ``prompt_tokens`` instead would understate the hit
|
||||
-- rate by however much traffic ran on a provider that
|
||||
-- reports nothing, since those rows are in one sum but
|
||||
-- not the other.
|
||||
COALESCE(
|
||||
SUM(tu.prompt_tokens)
|
||||
FILTER (WHERE tu.cached_tokens IS NOT NULL),
|
||||
0
|
||||
) AS cache_eligible_prompt_tokens
|
||||
{group_select}
|
||||
FROM token_usage tu
|
||||
{join}
|
||||
@@ -312,6 +333,15 @@ class TokenUsageRepository:
|
||||
"bucket": row._mapping["bucket"],
|
||||
"prompt_tokens": int(row._mapping["prompt_tokens"]),
|
||||
"generated_tokens": int(row._mapping["generated_tokens"]),
|
||||
"cost": float(row._mapping["cost"]),
|
||||
"cached_tokens": (
|
||||
int(row._mapping["cached_tokens"])
|
||||
if row._mapping["cached_tokens"] is not None
|
||||
else None
|
||||
),
|
||||
"cache_eligible_prompt_tokens": int(
|
||||
row._mapping["cache_eligible_prompt_tokens"]
|
||||
),
|
||||
**(
|
||||
{"group_key": row._mapping["group_key"]}
|
||||
if group_by is not None
|
||||
|
||||
+48
-5
@@ -93,7 +93,7 @@ def _count_prompt_tokens(messages, tools=None, usage_attachments=None, **kwargs)
|
||||
return prompt_tokens
|
||||
|
||||
|
||||
def _persist_call_usage(llm, call_usage):
|
||||
def _persist_call_usage(llm, call_usage, *, duration_ms=None, ttft_ms=None):
|
||||
"""Write one ``token_usage`` row per LLM call. Always-on; no flag.
|
||||
|
||||
Source defaults to ``agent_stream`` and can be overridden per
|
||||
@@ -101,6 +101,13 @@ def _persist_call_usage(llm, call_usage):
|
||||
title / compression / rag_condense / fallback). A ``_request_id``
|
||||
stamped on the LLM lets ``count_in_range`` deduplicate the multiple
|
||||
rows produced by a single multi-tool agent run.
|
||||
|
||||
Args:
|
||||
llm: The LLM instance the call ran on.
|
||||
call_usage: The call's token counts.
|
||||
duration_ms: Wall-clock for the call, measured by the wrapper.
|
||||
ttft_ms: Time to the first streamed chunk; None for a non-streaming
|
||||
call and for a stream that failed before yielding anything.
|
||||
"""
|
||||
if call_usage["prompt_tokens"] == 0 and call_usage["generated_tokens"] == 0:
|
||||
return
|
||||
@@ -149,6 +156,8 @@ def _persist_call_usage(llm, call_usage):
|
||||
),
|
||||
request_id=getattr(llm, "_request_id", None),
|
||||
model_id=model_id,
|
||||
duration_ms=duration_ms,
|
||||
ttft_ms=ttft_ms,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("token_usage persist failed")
|
||||
@@ -258,10 +267,12 @@ def gen_token_usage(func):
|
||||
error = exc
|
||||
raise
|
||||
finally:
|
||||
duration_ms = int((time.monotonic() - started_at) * 1000)
|
||||
call_usage = _prefer_provider_usage(self, call_usage)
|
||||
self.token_usage["prompt_tokens"] += call_usage["prompt_tokens"]
|
||||
self.token_usage["generated_tokens"] += call_usage["generated_tokens"]
|
||||
_persist_call_usage(self, call_usage)
|
||||
# A non-streaming call has no first-token moment; ttft stays NULL.
|
||||
_persist_call_usage(self, call_usage, duration_ms=duration_ms)
|
||||
emit = getattr(self, "_emit_gen_finished_log", None)
|
||||
if callable(emit):
|
||||
try:
|
||||
@@ -269,7 +280,7 @@ def gen_token_usage(func):
|
||||
model,
|
||||
prompt_tokens=call_usage["prompt_tokens"],
|
||||
completion_tokens=call_usage["generated_tokens"],
|
||||
latency_ms=int((time.monotonic() - started_at) * 1000),
|
||||
latency_ms=duration_ms,
|
||||
cached_tokens=call_usage.get("cached_tokens"),
|
||||
cache_write_tokens=call_usage.get("cache_write_tokens"),
|
||||
error=error,
|
||||
@@ -293,10 +304,29 @@ def stream_token_usage(func):
|
||||
)
|
||||
batch = []
|
||||
started_at = time.monotonic()
|
||||
first_chunk_at: float | None = None
|
||||
# Time spent waiting on the provider, accumulated across ``next()``
|
||||
# calls. The wall clock cannot be used here: this is a generator, so
|
||||
# every ``yield`` suspends until the consumer comes back, and the span
|
||||
# from start to exhaustion includes the agent loop's tool handling and
|
||||
# the SSE client's backpressure. A slow browser would otherwise record
|
||||
# 30s for a 900ms call, and latency_summary would mix that with true
|
||||
# non-streaming durations under one p50.
|
||||
provider_seconds = 0.0
|
||||
error: BaseException | None = None
|
||||
try:
|
||||
result = func(self, model, messages, stream, tools, **kwargs)
|
||||
for r in result:
|
||||
stream_iter = iter(result)
|
||||
while True:
|
||||
pull_started = time.monotonic()
|
||||
try:
|
||||
r = next(stream_iter)
|
||||
except StopIteration:
|
||||
provider_seconds += time.monotonic() - pull_started
|
||||
break
|
||||
provider_seconds += time.monotonic() - pull_started
|
||||
if first_chunk_at is None:
|
||||
first_chunk_at = pull_started + provider_seconds
|
||||
batch.append(r)
|
||||
yield r
|
||||
except Exception as exc:
|
||||
@@ -306,12 +336,22 @@ def stream_token_usage(func):
|
||||
error = exc
|
||||
raise
|
||||
finally:
|
||||
duration_ms = int(provider_seconds * 1000)
|
||||
# NULL, not 0, when the stream failed before yielding: "no first
|
||||
# token" must not read as an instant one in a p50.
|
||||
ttft_ms = (
|
||||
int((first_chunk_at - started_at) * 1000)
|
||||
if first_chunk_at is not None
|
||||
else None
|
||||
)
|
||||
for line in batch:
|
||||
call_usage["generated_tokens"] += _count_tokens(line)
|
||||
call_usage = _prefer_provider_usage(self, call_usage)
|
||||
self.token_usage["prompt_tokens"] += call_usage["prompt_tokens"]
|
||||
self.token_usage["generated_tokens"] += call_usage["generated_tokens"]
|
||||
_persist_call_usage(self, call_usage)
|
||||
_persist_call_usage(
|
||||
self, call_usage, duration_ms=duration_ms, ttft_ms=ttft_ms
|
||||
)
|
||||
emit = getattr(self, "_emit_stream_finished_log", None)
|
||||
if callable(emit):
|
||||
try:
|
||||
@@ -319,6 +359,9 @@ def stream_token_usage(func):
|
||||
model,
|
||||
prompt_tokens=call_usage["prompt_tokens"],
|
||||
completion_tokens=call_usage["generated_tokens"],
|
||||
# The log line has always meant end-to-end wall clock
|
||||
# for the streamed response; only the persisted column
|
||||
# isolates provider time.
|
||||
latency_ms=int((time.monotonic() - started_at) * 1000),
|
||||
cached_tokens=call_usage.get("cached_tokens"),
|
||||
cache_write_tokens=call_usage.get("cache_write_tokens"),
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import { ChevronDown, ChevronRight, Download } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import adminService, {
|
||||
type ActivityFilters,
|
||||
} from '../api/services/adminService';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { MultiSelect } from '../components/ui/multi-select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '../components/ui/table';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import {
|
||||
Loading,
|
||||
Pill,
|
||||
categoryTone,
|
||||
eventLabel,
|
||||
eventTone,
|
||||
fmtDate,
|
||||
fmtNumber,
|
||||
fmtRelative,
|
||||
isLoopback,
|
||||
outcomeLabel,
|
||||
outcomeTone,
|
||||
} from './AdminUI';
|
||||
|
||||
type ActivityRow = {
|
||||
feed: string;
|
||||
id: string;
|
||||
event: string;
|
||||
category: string;
|
||||
actor_id: string | null;
|
||||
target_id: string | null;
|
||||
ip: string | null;
|
||||
user_agent: string | null;
|
||||
outcome: string | null;
|
||||
detail: Record<string, unknown> | null;
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
type Catalogue = {
|
||||
events: { event: string; category: string }[];
|
||||
categories: string[];
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/** Quick ranges, in days. `0` means "no lower bound". */
|
||||
const RANGES: { label: string; days: number }[] = [
|
||||
{ label: '24h', days: 1 },
|
||||
{ label: '7d', days: 7 },
|
||||
{ label: '30d', days: 30 },
|
||||
{ label: '90d', days: 90 },
|
||||
{ label: 'All', days: 0 },
|
||||
];
|
||||
|
||||
function sinceFor(days: number): string | undefined {
|
||||
if (days <= 0) return undefined;
|
||||
return new Date(Date.now() - days * 86400000).toISOString();
|
||||
}
|
||||
|
||||
/** One detail value, rendered as a readable line rather than raw JSON. */
|
||||
function DetailRow({ label, value }: { label: string; value: unknown }) {
|
||||
const text =
|
||||
typeof value === 'object' && value !== null
|
||||
? JSON.stringify(value)
|
||||
: String(value);
|
||||
return (
|
||||
<div className="flex gap-2 text-[13px] break-all">
|
||||
<span className="text-muted-foreground min-w-32 shrink-0">{label}</span>
|
||||
<span className="font-mono">{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The expanded row. auth_events captures `metadata` and `user_agent` on every
|
||||
* insert and the table showed neither, so "Admin revoked · user-42" never said
|
||||
* who revoked it. Everything the feed carries lands here.
|
||||
*/
|
||||
function RowDetail({ row }: { row: ActivityRow }) {
|
||||
const detail = row.detail ?? {};
|
||||
const entries = Object.entries(detail);
|
||||
return (
|
||||
<div className="bg-muted/40 space-y-1 rounded-lg px-4 py-3">
|
||||
<DetailRow label="Event" value={row.event} />
|
||||
<DetailRow label="Journal" value={row.feed} />
|
||||
{row.actor_id ? <DetailRow label="Actor" value={row.actor_id} /> : null}
|
||||
{row.target_id ? (
|
||||
<DetailRow label="Affected user" value={row.target_id} />
|
||||
) : null}
|
||||
{row.outcome ? <DetailRow label="Outcome" value={row.outcome} /> : null}
|
||||
{row.ip ? <DetailRow label="IP" value={row.ip} /> : null}
|
||||
{row.user_agent ? (
|
||||
<DetailRow label="User agent" value={row.user_agent} />
|
||||
) : null}
|
||||
<DetailRow label="Recorded" value={fmtDate(row.created_at)} />
|
||||
{entries.length > 0 ? (
|
||||
<div className="border-border mt-2 space-y-1 border-t pt-2">
|
||||
{entries.map(([key, value]) => (
|
||||
<DetailRow key={key} label={key} value={value} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Activity() {
|
||||
const token = useSelector(selectToken);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Deep links from Overview land here pre-filtered (?event=oidc_login_denied).
|
||||
const initialEvent = searchParams.get('event');
|
||||
|
||||
const [rows, setRows] = useState<ActivityRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [catalogue, setCatalogue] = useState<Catalogue | null>(null);
|
||||
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [events, setEvents] = useState<string[]>(
|
||||
initialEvent ? [initialEvent] : [],
|
||||
);
|
||||
const [rangeDays, setRangeDays] = useState(30);
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
/**
|
||||
* Narrowing the feed invalidates whatever page you were on, so every filter
|
||||
* setter resets it. Resetting in an effect instead would fetch the old page
|
||||
* against the new filters first, then fetch again.
|
||||
*/
|
||||
const withPageReset =
|
||||
<T,>(set: (value: T) => void) =>
|
||||
(value: T) => {
|
||||
setPage(1);
|
||||
set(value);
|
||||
};
|
||||
|
||||
const filters: ActivityFilters = useMemo(
|
||||
() => ({
|
||||
category: categories.length ? categories : undefined,
|
||||
event: events.length ? events : undefined,
|
||||
since: sinceFor(rangeDays),
|
||||
search: search || undefined,
|
||||
}),
|
||||
[categories, events, rangeDays, search],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
adminService
|
||||
.getActivityEvents(token)
|
||||
.then((res) => res.json())
|
||||
.then((json) => {
|
||||
if (!cancelled && json?.success) setCatalogue(json);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
// Monotonic request id: a response only lands if no newer request was
|
||||
// issued meanwhile, so an out-of-order reply cannot leave the table showing
|
||||
// a different filter combination than the controls. Mirrors the guard in
|
||||
// settings/Analytics.
|
||||
const requestId = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const id = ++requestId.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminService.getActivity(
|
||||
{ ...filters, page, page_size: PAGE_SIZE },
|
||||
token,
|
||||
);
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (id !== requestId.current) return;
|
||||
setRows(json.activity ?? []);
|
||||
setTotal(json.total ?? 0);
|
||||
} finally {
|
||||
if (id === requestId.current) setLoading(false);
|
||||
}
|
||||
}, [token, page, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const exportAs = async (format: 'csv' | 'ndjson') => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const res = await adminService.exportActivity(filters, format, token);
|
||||
if (!res.ok) return;
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `docsgpt-activity.${format}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const eventOptions = useMemo(
|
||||
() =>
|
||||
(catalogue?.events ?? []).map(({ event }) => ({
|
||||
value: event,
|
||||
label: eventLabel(event),
|
||||
})),
|
||||
[catalogue],
|
||||
);
|
||||
const categoryOptions = useMemo(
|
||||
() =>
|
||||
(catalogue?.categories ?? []).map((category) => ({
|
||||
value: category,
|
||||
label: category.charAt(0).toUpperCase() + category.slice(1),
|
||||
})),
|
||||
[catalogue],
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const hasFilters =
|
||||
categories.length > 0 || events.length > 0 || search !== '';
|
||||
|
||||
const applySearch = () => withPageReset(setSearch)(searchDraft.trim());
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search user, IP or detail"
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applySearch()}
|
||||
onBlur={applySearch}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<MultiSelect
|
||||
options={categoryOptions}
|
||||
selected={categories}
|
||||
onChange={withPageReset(setCategories)}
|
||||
placeholder="All categories"
|
||||
className="w-48"
|
||||
/>
|
||||
<MultiSelect
|
||||
options={eventOptions}
|
||||
selected={events}
|
||||
onChange={withPageReset(setEvents)}
|
||||
placeholder="All events"
|
||||
searchPlaceholder="Find an event"
|
||||
className="w-56"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{RANGES.map((range) => (
|
||||
<Button
|
||||
key={range.label}
|
||||
variant={range.days === rangeDays ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-3xl"
|
||||
onClick={() => withPageReset(setRangeDays)(range.days)}
|
||||
>
|
||||
{range.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setCategories([]);
|
||||
setEvents([]);
|
||||
setSearchDraft('');
|
||||
setSearch('');
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={exporting}
|
||||
onClick={() => exportAs('csv')}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={exporting}
|
||||
onClick={() => exportAs('ndjson')}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
NDJSON
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-muted-foreground mt-8 text-sm">No activity.</p>
|
||||
) : (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader className="w-8" />
|
||||
<TableHeader>Event</TableHeader>
|
||||
<TableHeader>Category</TableHeader>
|
||||
<TableHeader>Actor</TableHeader>
|
||||
<TableHeader>Affected</TableHeader>
|
||||
<TableHeader>IP</TableHeader>
|
||||
<TableHeader>When</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row, idx) => {
|
||||
const key = `${row.feed}:${row.id}`;
|
||||
const isOpen = expanded === key;
|
||||
// De-emphasize an actor repeated from the row above (ditto),
|
||||
// so distinct actors stand out in a long single-user stream.
|
||||
const repeat =
|
||||
idx > 0 && rows[idx - 1].actor_id === row.actor_id;
|
||||
return [
|
||||
<TableRow key={key} className="hover:bg-muted/40">
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={
|
||||
isOpen
|
||||
? `Hide details for ${eventLabel(row.event)}`
|
||||
: `Show details for ${eventLabel(row.event)}`
|
||||
}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring cursor-pointer rounded focus-visible:ring-2 focus-visible:outline-none"
|
||||
onClick={() => setExpanded(isOpen ? null : key)}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill tone={eventTone(row.event)}>
|
||||
{eventLabel(row.event)}
|
||||
</Pill>
|
||||
{row.outcome ? (
|
||||
<Pill tone={outcomeTone(row.outcome)}>
|
||||
{outcomeLabel(row.outcome)}
|
||||
</Pill>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Pill tone={categoryTone(row.category)}>
|
||||
{row.category}
|
||||
</Pill>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`font-mono text-[13px] ${
|
||||
repeat ? 'text-muted-foreground/50' : ''
|
||||
}`}
|
||||
>
|
||||
{repeat ? '〃' : (row.actor_id ?? '—')}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[13px]">
|
||||
{row.target_id && row.target_id !== row.actor_id
|
||||
? row.target_id
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{row.ip === null ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
—
|
||||
</span>
|
||||
) : isLoopback(row.ip) ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
local
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-[13px]">
|
||||
{row.ip}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground whitespace-nowrap">
|
||||
<span title={fmtDate(row.created_at)}>
|
||||
{fmtRelative(row.created_at)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>,
|
||||
isOpen ? (
|
||||
<TableRow key={`${key}:detail`}>
|
||||
<TableCell colSpan={7} className="py-2">
|
||||
<RowDetail row={row} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null,
|
||||
];
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{fmtNumber(total)} events · page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
categoryTone,
|
||||
eventLabel,
|
||||
eventTone,
|
||||
outcomeLabel,
|
||||
outcomeTone,
|
||||
} from './AdminUI';
|
||||
|
||||
describe('eventLabel', () => {
|
||||
it('humanizes the events it knows', () => {
|
||||
expect(eventLabel('oidc_login_denied')).toBe('Login denied');
|
||||
expect(eventLabel('source.deleted')).toBe('Source deleted');
|
||||
expect(eventLabel('device.run_command')).toBe('Device command');
|
||||
});
|
||||
|
||||
it('stays legible for an event a newer release added', () => {
|
||||
expect(eventLabel('source.archived')).toBe('Source archived');
|
||||
expect(eventLabel('brand_new_event')).toBe('Brand new event');
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventTone', () => {
|
||||
it('flags removals and refusals', () => {
|
||||
expect(eventTone('oidc_login_denied')).toBe('danger');
|
||||
expect(eventTone('agent.deleted')).toBe('danger');
|
||||
});
|
||||
|
||||
it('flags revocations as a warning, not a failure', () => {
|
||||
expect(eventTone('role_revoked')).toBe('warning');
|
||||
expect(eventTone('agent.key_regenerated')).toBe('warning');
|
||||
});
|
||||
|
||||
it('marks an admin grant with the brand tone', () => {
|
||||
expect(eventTone('role_granted')).toBe('brand');
|
||||
});
|
||||
|
||||
it('leaves routine events muted', () => {
|
||||
expect(eventTone('oidc_login')).toBe('muted');
|
||||
expect(eventTone('anything_else')).toBe('muted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('categoryTone', () => {
|
||||
it('has a tone for every category the API declares', () => {
|
||||
for (const category of [
|
||||
'identity',
|
||||
'access',
|
||||
'config',
|
||||
'data',
|
||||
'device',
|
||||
'safety',
|
||||
'other',
|
||||
]) {
|
||||
expect(categoryTone(category)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back for an unknown category', () => {
|
||||
expect(categoryTone('invented')).toBe('muted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('outcomeTone', () => {
|
||||
// The values below are the only ones the writers produce: guardrails emit
|
||||
// triggered/not_evaluated, the device feed emits dispatched.
|
||||
it('flags a guardrail that fired', () => {
|
||||
expect(outcomeTone('triggered')).toBe('danger');
|
||||
});
|
||||
|
||||
it('leaves a check that never ran neutral', () => {
|
||||
expect(outcomeTone('not_evaluated')).toBe('muted');
|
||||
});
|
||||
|
||||
it('reads a dispatched device command as a success', () => {
|
||||
expect(outcomeTone('dispatched')).toBe('success');
|
||||
});
|
||||
|
||||
it('leaves an unfamiliar outcome muted', () => {
|
||||
expect(outcomeTone('something-new')).toBe('muted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('outcomeLabel', () => {
|
||||
it('humanizes the values the journals write', () => {
|
||||
expect(outcomeLabel('not_evaluated')).toBe('Not evaluated');
|
||||
expect(outcomeLabel('dispatched')).toBe('Dispatched');
|
||||
});
|
||||
|
||||
it('stays legible for an unknown value', () => {
|
||||
expect(outcomeLabel('rate_limited')).toBe('Rate limited');
|
||||
});
|
||||
});
|
||||
+124
-13
@@ -97,6 +97,28 @@ export function fmtNumber(n?: number | null): string {
|
||||
return new Intl.NumberFormat().format(n ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* USD, with enough precision that a fraction of a cent is not rendered as $0.
|
||||
* An instance on a cheap model can run whole days under a dollar, and "$0"
|
||||
* next to a cost quota reads as "nothing is being counted".
|
||||
*/
|
||||
export function fmtUsd(n?: number | null): string {
|
||||
const value = n ?? 0;
|
||||
const digits = value !== 0 && Math.abs(value) < 0.01 ? 4 : 2;
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: digits,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/** Milliseconds as ms or s, or an em dash when nothing was measured. */
|
||||
export function fmtMs(n?: number | null): string {
|
||||
if (n === null || n === undefined) return '—';
|
||||
return n < 1000 ? `${Math.round(n)}ms` : `${(n / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export function fmtCompact(n?: number | null): string {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
notation: 'compact',
|
||||
@@ -104,8 +126,10 @@ export function fmtCompact(n?: number | null): string {
|
||||
}).format(n ?? 0);
|
||||
}
|
||||
|
||||
// Humanized auth-event labels + semantic color tier, shared by the Audit feed
|
||||
// Humanized event labels + semantic color tier, shared by the activity feed
|
||||
// and the user-detail modal so the same event reads identically everywhere.
|
||||
// Unknown names fall back to a title-cased form of the name itself, so an
|
||||
// event added by a newer release is still legible here.
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
oidc_login: 'Login',
|
||||
oidc_login_denied: 'Login denied',
|
||||
@@ -121,28 +145,115 @@ const EVENT_LABELS: Record<string, string> = {
|
||||
scim_reactivated: 'Activated (SCIM)',
|
||||
quota_policy_set: 'Quota set',
|
||||
quota_policy_deleted: 'Quota removed',
|
||||
pat_created: 'Token created',
|
||||
pat_revoked: 'Token revoked',
|
||||
pat_regenerated: 'Token regenerated',
|
||||
'team.create': 'Team created',
|
||||
'team.delete': 'Team deleted',
|
||||
'team.member_add': 'Member added',
|
||||
'team.member_role': 'Member role changed',
|
||||
'team.member_remove': 'Member removed',
|
||||
'team.share': 'Resource shared',
|
||||
'team.unshare': 'Resource unshared',
|
||||
'team.transfer_owner': 'Ownership transferred',
|
||||
'source.created': 'Source created',
|
||||
'source.deleted': 'Source deleted',
|
||||
'source.reingested': 'Source reingested',
|
||||
'agent.created': 'Agent created',
|
||||
'agent.updated': 'Agent updated',
|
||||
'agent.deleted': 'Agent deleted',
|
||||
'agent.key_regenerated': 'Agent key rotated',
|
||||
'conversation.deleted': 'Conversation deleted',
|
||||
'conversation.deleted_all': 'All conversations deleted',
|
||||
'device.run_command': 'Device command',
|
||||
'guardrail.input': 'Guardrail (input)',
|
||||
'guardrail.retrieval': 'Guardrail (retrieval)',
|
||||
'guardrail.tool_result': 'Guardrail (tool result)',
|
||||
'guardrail.output': 'Guardrail (output)',
|
||||
};
|
||||
|
||||
export function eventLabel(event: string): string {
|
||||
return (
|
||||
EVENT_LABELS[event] ??
|
||||
event.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase())
|
||||
);
|
||||
const known = EVENT_LABELS[event];
|
||||
if (known) return known;
|
||||
// "source.reingested" -> "Source reingested"
|
||||
return event.replace(/[._]/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// Events that describe something being taken away or refused.
|
||||
const DANGER_EVENTS = new Set([
|
||||
'oidc_login_denied',
|
||||
'admin_user_deactivated',
|
||||
'scim_deactivated',
|
||||
'source.deleted',
|
||||
'agent.deleted',
|
||||
'conversation.deleted',
|
||||
'conversation.deleted_all',
|
||||
'team.delete',
|
||||
]);
|
||||
|
||||
const WARNING_EVENTS = new Set([
|
||||
'role_revoked',
|
||||
'admin_sessions_revoked',
|
||||
'pat_revoked',
|
||||
'agent.key_regenerated',
|
||||
'quota_policy_deleted',
|
||||
'team.member_remove',
|
||||
'team.unshare',
|
||||
]);
|
||||
|
||||
export function eventTone(event: string): Tone {
|
||||
if (
|
||||
event === 'oidc_login_denied' ||
|
||||
event === 'admin_user_deactivated' ||
|
||||
event === 'scim_deactivated'
|
||||
)
|
||||
return 'danger';
|
||||
if (DANGER_EVENTS.has(event)) return 'danger';
|
||||
if (event === 'role_granted') return 'brand';
|
||||
if (event === 'role_revoked' || event === 'admin_sessions_revoked')
|
||||
return 'warning';
|
||||
if (WARNING_EVENTS.has(event)) return 'warning';
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
// Category facet colors. Mirrors docsgpt/audit_events.py.
|
||||
const CATEGORY_TONES: Record<string, Tone> = {
|
||||
identity: 'default',
|
||||
access: 'brand',
|
||||
config: 'warning',
|
||||
data: 'success',
|
||||
device: 'warning',
|
||||
safety: 'danger',
|
||||
other: 'muted',
|
||||
};
|
||||
|
||||
export function categoryTone(category: string): Tone {
|
||||
return CATEGORY_TONES[category] ?? 'muted';
|
||||
}
|
||||
|
||||
// Outcome values the two side journals actually write.
|
||||
//
|
||||
// guardrail_events.outcome is "triggered" / "not_evaluated"
|
||||
// (guardrails/runtime.py) and its .action carries flag/block; the device feed
|
||||
// writes decision="dispatched" (agents/tools/remote_device.py). An earlier
|
||||
// map here guessed at blocked/denied/allowed, none of which are ever
|
||||
// produced, so every outcome rendered neutral grey -- including a guardrail
|
||||
// that fired, which is the one signal the merged feed exists to surface.
|
||||
const OUTCOME_TONES: Record<string, Tone> = {
|
||||
triggered: 'danger',
|
||||
not_evaluated: 'muted',
|
||||
dispatched: 'success',
|
||||
};
|
||||
|
||||
export function outcomeTone(outcome: string): Tone {
|
||||
return OUTCOME_TONES[outcome] ?? 'muted';
|
||||
}
|
||||
|
||||
const OUTCOME_LABELS: Record<string, string> = {
|
||||
triggered: 'Triggered',
|
||||
not_evaluated: 'Not evaluated',
|
||||
dispatched: 'Dispatched',
|
||||
};
|
||||
|
||||
export function outcomeLabel(outcome: string): string {
|
||||
return (
|
||||
OUTCOME_LABELS[outcome] ??
|
||||
outcome.replace(/[._]/g, ' ').replace(/^\w/, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
// 127.0.0.1 / ::1 are noise in dev — collapse to a muted "local" chip so real
|
||||
// external IPs stand out in the audit feed.
|
||||
export function isLoopback(ip?: string | null): boolean {
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import adminService from '../api/services/adminService';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '../components/ui/table';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import {
|
||||
Loading,
|
||||
Pill,
|
||||
eventLabel,
|
||||
eventTone,
|
||||
fmtDate,
|
||||
fmtNumber,
|
||||
fmtRelative,
|
||||
isLoopback,
|
||||
} from './AdminUI';
|
||||
|
||||
type Event = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
event: string;
|
||||
ip?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export default function Audit() {
|
||||
const token = useSelector(selectToken);
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialEvent = searchParams.get('event') ?? '';
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [eventFilter, setEventFilter] = useState(initialEvent);
|
||||
const [userFilter, setUserFilter] = useState('');
|
||||
const [search, setSearch] = useState({ event: initialEvent, user_id: '' });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminService.getAudit(
|
||||
{
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
event: search.event || undefined,
|
||||
user_id: search.user_id || undefined,
|
||||
},
|
||||
token,
|
||||
);
|
||||
const json = await res.json().catch(() => ({}));
|
||||
setEvents(json.events ?? []);
|
||||
setTotal(json.total ?? 0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const applyFilters = () => {
|
||||
setPage(1);
|
||||
setSearch({ event: eventFilter.trim(), user_id: userFilter.trim() });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Event (e.g. oidc_login_denied)"
|
||||
value={eventFilter}
|
||||
onChange={(e) => setEventFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="User id"
|
||||
value={userFilter}
|
||||
onChange={(e) => setUserFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={applyFilters}>
|
||||
Filter
|
||||
</Button>
|
||||
{(search.event || search.user_id) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEventFilter('');
|
||||
setUserFilter('');
|
||||
setPage(1);
|
||||
setSearch({ event: '', user_id: '' });
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : events.length === 0 ? (
|
||||
<p className="text-muted-foreground mt-8 text-sm">No events.</p>
|
||||
) : (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Event</TableHeader>
|
||||
<TableHeader>User</TableHeader>
|
||||
<TableHeader>IP</TableHeader>
|
||||
<TableHeader>When</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{events.map((ev, idx) => {
|
||||
// De-emphasize a user id repeated from the row above (ditto),
|
||||
// so distinct users stand out in a long single-user stream.
|
||||
const repeat =
|
||||
idx > 0 && events[idx - 1].user_id === ev.user_id;
|
||||
return (
|
||||
<TableRow key={ev.id}>
|
||||
<TableCell>
|
||||
<Pill tone={eventTone(ev.event)}>
|
||||
{eventLabel(ev.event)}
|
||||
</Pill>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`font-mono text-[13px] ${
|
||||
repeat ? 'text-muted-foreground/50' : ''
|
||||
}`}
|
||||
>
|
||||
{repeat ? '〃' : ev.user_id}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{isLoopback(ev.ip) ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
local
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-[13px]">
|
||||
{ev.ip ?? '—'}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground whitespace-nowrap">
|
||||
<span title={fmtDate(ev.created_at)}>
|
||||
{fmtRelative(ev.created_at)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{fmtNumber(total)} events · page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+145
-51
@@ -4,6 +4,13 @@ import { useSelector } from 'react-redux';
|
||||
import adminService from '../api/services/adminService';
|
||||
import SkeletonLoader from '../components/SkeletonLoader';
|
||||
import { Button } from '../components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -15,16 +22,33 @@ import {
|
||||
} from '../components/ui/table';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { formatDate } from '../utils/dateTimeUtils';
|
||||
import { Loading, LoadError, StatCard, fmtNumber } from './AdminUI';
|
||||
import UsageChart, { usageColors } from './UsageChart';
|
||||
import {
|
||||
Loading,
|
||||
LoadError,
|
||||
StatCard,
|
||||
fmtMs,
|
||||
fmtNumber,
|
||||
fmtUsd,
|
||||
} from './AdminUI';
|
||||
import UsageChart from './UsageChart';
|
||||
import {
|
||||
GROUP_OPTIONS,
|
||||
buildUsageChart,
|
||||
cacheHitRate as computeCacheHitRate,
|
||||
type GroupBy,
|
||||
type Metric,
|
||||
type UsageBucket,
|
||||
} from './usageChartData';
|
||||
import UserUsageModal from './UserUsageModal';
|
||||
|
||||
type Bucket = {
|
||||
bucket: string;
|
||||
prompt_tokens: number;
|
||||
generated_tokens: number;
|
||||
type TopUser = { user_id: string; tokens: number; cost: number };
|
||||
type Latency = {
|
||||
samples: number;
|
||||
p50_ms: number | null;
|
||||
p95_ms: number | null;
|
||||
ttft_samples: number;
|
||||
ttft_p50_ms: number | null;
|
||||
};
|
||||
type TopUser = { user_id: string; tokens: number };
|
||||
|
||||
const RANGES = [7, 30, 90];
|
||||
|
||||
@@ -32,47 +56,40 @@ export default function Usage() {
|
||||
const token = useSelector(selectToken);
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
const [days, setDays] = useState(30);
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>('none');
|
||||
const [metric, setMetric] = useState<Metric>('tokens');
|
||||
const [data, setData] = useState<any | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [drilldown, setDrilldown] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminService.getUsage({ days, bucket: 'day' }, token);
|
||||
const res = await adminService.getUsage(
|
||||
{ days, bucket: 'day', group_by: groupBy },
|
||||
token,
|
||||
);
|
||||
setData(await res.json().catch(() => ({})));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, days]);
|
||||
}, [token, days, groupBy]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const series: Bucket[] = data?.series ?? [];
|
||||
const series: UsageBucket[] = data?.series ?? [];
|
||||
const topUsers: TopUser[] = data?.top_users ?? [];
|
||||
const promptTotal = series.reduce((sum, b) => sum + b.prompt_tokens, 0);
|
||||
const generatedTotal = series.reduce((sum, b) => sum + b.generated_tokens, 0);
|
||||
const latency: Latency | undefined = data?.latency;
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const colors = usageColors();
|
||||
return {
|
||||
labels: series.map((b) => formatDate(b.bucket)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Prompt',
|
||||
data: series.map((b) => b.prompt_tokens),
|
||||
backgroundColor: colors.prompt,
|
||||
},
|
||||
{
|
||||
label: 'Generated',
|
||||
data: series.map((b) => b.generated_tokens),
|
||||
backgroundColor: colors.generated,
|
||||
},
|
||||
],
|
||||
};
|
||||
const chartData = useMemo(
|
||||
() => buildUsageChart(series, groupBy, metric),
|
||||
// isDarkTheme re-resolves the canvas colors when the theme toggles.
|
||||
}, [series, isDarkTheme]);
|
||||
[series, groupBy, metric, isDarkTheme],
|
||||
);
|
||||
|
||||
const cacheHitRate = useMemo(() => computeCacheHitRate(series), [series]);
|
||||
|
||||
if (data === null && loading) return <Loading />;
|
||||
if (data && !data.success)
|
||||
@@ -80,34 +97,89 @@ export default function Usage() {
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="mb-4 flex items-center gap-1">
|
||||
{RANGES.map((r) => (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{RANGES.map((range) => (
|
||||
<Button
|
||||
key={range}
|
||||
variant={range === days ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-3xl"
|
||||
onClick={() => setDays(range)}
|
||||
>
|
||||
{range}d
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Select
|
||||
value={groupBy}
|
||||
onValueChange={(value) => setGroupBy(value as GroupBy)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GROUP_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
key={r}
|
||||
variant={r === days ? 'default' : 'outline'}
|
||||
variant={metric === 'tokens' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-3xl"
|
||||
onClick={() => setDays(r)}
|
||||
onClick={() => setMetric('tokens')}
|
||||
>
|
||||
{r}d
|
||||
Tokens
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant={metric === 'cost' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-3xl"
|
||||
onClick={() => setMetric('cost')}
|
||||
>
|
||||
Cost
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<StatCard
|
||||
label={`Total tokens (${days}d)`}
|
||||
label={`Spend (${days}d)`}
|
||||
value={fmtUsd(data?.total_cost)}
|
||||
sub="Unpriced and BYO models record $0"
|
||||
/>
|
||||
<StatCard
|
||||
label={`Tokens (${days}d)`}
|
||||
value={fmtNumber(data?.total_tokens)}
|
||||
/>
|
||||
<StatCard label="Prompt tokens" value={fmtNumber(promptTotal)} />
|
||||
<StatCard label="Generated tokens" value={fmtNumber(generatedTotal)} />
|
||||
<StatCard label="Days with usage" value={fmtNumber(series.length)} />
|
||||
<StatCard
|
||||
label="Latency p50 / p95"
|
||||
value={`${fmtMs(latency?.p50_ms)} / ${fmtMs(latency?.p95_ms)}`}
|
||||
sub={
|
||||
latency?.ttft_p50_ms !== null && latency?.ttft_p50_ms !== undefined
|
||||
? `First token ${fmtMs(latency.ttft_p50_ms)} (p50)`
|
||||
: 'No streamed calls measured'
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Prompt cache hits"
|
||||
value={cacheHitRate === null ? '—' : `${cacheHitRate.toFixed(1)}%`}
|
||||
sub={
|
||||
cacheHitRate === null
|
||||
? 'Provider reported no cache data'
|
||||
: 'Of prompt tokens on calls that reported'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-border dark:border-border mt-4 h-[345px] w-full overflow-hidden rounded-2xl border px-6 py-5">
|
||||
<div className="flex flex-row items-center justify-between gap-3">
|
||||
<p className="text-foreground dark:text-foreground font-bold">
|
||||
Token usage
|
||||
{metric === 'cost' ? 'Spend' : 'Token usage'}
|
||||
</p>
|
||||
<div
|
||||
id="admin-usage-legend"
|
||||
@@ -122,14 +194,18 @@ export default function Usage() {
|
||||
No usage in this period.
|
||||
</p>
|
||||
) : (
|
||||
<UsageChart data={chartData} legendID="admin-usage-legend" />
|
||||
<UsageChart
|
||||
data={chartData}
|
||||
legendID="admin-usage-legend"
|
||||
currency={metric === 'cost'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border dark:border-border mt-4 w-full overflow-hidden rounded-2xl border px-6 py-5">
|
||||
<p className="text-foreground dark:text-foreground mb-3 font-bold">
|
||||
Top users by tokens
|
||||
Top users
|
||||
</p>
|
||||
{topUsers.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No usage.</p>
|
||||
@@ -139,17 +215,33 @@ export default function Usage() {
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>User</TableHeader>
|
||||
<TableHeader className="text-right">Tokens</TableHeader>
|
||||
<TableHeader align="right">Tokens</TableHeader>
|
||||
<TableHeader align="right">Cost</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{topUsers.map((u) => (
|
||||
<TableRow key={u.user_id}>
|
||||
{topUsers.map((user) => (
|
||||
<TableRow key={user.user_id} className="hover:bg-muted/40">
|
||||
<TableCell className="font-mono text-[13px] break-all">
|
||||
{u.user_id}
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-foreground focus-visible:ring-ring cursor-pointer rounded text-left underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
|
||||
onClick={() => setDrilldown(user.user_id)}
|
||||
>
|
||||
{user.user_id}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap tabular-nums">
|
||||
{fmtNumber(u.tokens)}
|
||||
<TableCell
|
||||
align="right"
|
||||
className="whitespace-nowrap tabular-nums"
|
||||
>
|
||||
{fmtNumber(user.tokens)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
className="whitespace-nowrap tabular-nums"
|
||||
>
|
||||
{fmtUsd(user.cost)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -158,6 +250,8 @@ export default function Usage() {
|
||||
</TableContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UserUsageModal userId={drilldown} onClose={() => setDrilldown(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,25 @@ export function usageColors(): { prompt: string; generated: string } {
|
||||
};
|
||||
}
|
||||
|
||||
// Follow-on colors for a grouped chart (by model / agent / source), matching
|
||||
// the palette settings/Analytics already uses for its stacked series.
|
||||
const SERIES_COLORS = [
|
||||
'#FF6384',
|
||||
'#36A2EB',
|
||||
'#FFCE56',
|
||||
'#4BC0C0',
|
||||
'#9966FF',
|
||||
'#FF9F40',
|
||||
'#2BC596',
|
||||
];
|
||||
|
||||
/** Color for dataset ``index``; the first uses the resolved brand color. */
|
||||
export function seriesColor(index: number): string {
|
||||
return index === 0
|
||||
? readCssVar('--primary', '#7D54D1')
|
||||
: SERIES_COLORS[(index - 1) % SERIES_COLORS.length];
|
||||
}
|
||||
|
||||
function compactTick(value: number | string): string {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
if (Number.isNaN(n)) return String(value);
|
||||
@@ -48,16 +67,28 @@ function compactTick(value: number | string): string {
|
||||
}).format(n);
|
||||
}
|
||||
|
||||
function currencyTick(value: number | string): string {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
if (Number.isNaN(n)) return String(value);
|
||||
return `$${new Intl.NumberFormat(undefined, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(n)}`;
|
||||
}
|
||||
|
||||
type UsageChartProps = {
|
||||
data: ChartData<'bar'>;
|
||||
legendID: string;
|
||||
maxTicksLimitInX?: number;
|
||||
/** Format the y axis as USD rather than a token count. */
|
||||
currency?: boolean;
|
||||
};
|
||||
|
||||
export default function UsageChart({
|
||||
data,
|
||||
legendID,
|
||||
maxTicksLimitInX = 8,
|
||||
currency = false,
|
||||
}: UsageChartProps) {
|
||||
const options = {
|
||||
responsive: true,
|
||||
@@ -76,7 +107,7 @@ export default function UsageChart({
|
||||
y: {
|
||||
grid: { lineWidth: 0.2, color: '#C4C4C4' },
|
||||
border: { width: 0.2, color: '#C4C4C4' },
|
||||
ticks: { callback: compactTick },
|
||||
ticks: { callback: currency ? currencyTick : compactTick },
|
||||
stacked: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import adminService from '../api/services/adminService';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '../components/ui/table';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { formatDate } from '../utils/dateTimeUtils';
|
||||
import { Loading, LoadError, StatCard, fmtNumber, fmtUsd } from './AdminUI';
|
||||
import UsageChart, { usageColors } from './UsageChart';
|
||||
|
||||
type Bucket = {
|
||||
bucket: string;
|
||||
prompt_tokens: number;
|
||||
generated_tokens: number;
|
||||
cost: number;
|
||||
};
|
||||
type Split = { key: string; tokens: number; cost: number };
|
||||
|
||||
const RANGES = [7, 30, 90];
|
||||
|
||||
/**
|
||||
* Label for a token_usage.source value — what the spend was for.
|
||||
*
|
||||
* No `schedule` entry: that source marks the run-level rollup row, which the
|
||||
* breakdown excludes to avoid double-counting. A scheduled run's individual
|
||||
* calls are recorded as `agent_stream`, so its spend appears under Chat.
|
||||
*/
|
||||
const FLOW_LABELS: Record<string, string> = {
|
||||
agent_stream: 'Chat',
|
||||
webhook: 'Webhook',
|
||||
workflow: 'Workflow',
|
||||
title: 'Title generation',
|
||||
compression: 'History compression',
|
||||
rag_condense: 'Question condensing',
|
||||
fallback: 'Provider fallback',
|
||||
};
|
||||
|
||||
function SplitTable({
|
||||
title,
|
||||
rows,
|
||||
labels,
|
||||
}: {
|
||||
title: string;
|
||||
rows: Split[];
|
||||
/** Only the flow table maps keys; a model named `fallback` is a model. */
|
||||
labels?: Record<string, string>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-2 text-sm font-medium">{title}</p>
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No usage.</p>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>{title}</TableHeader>
|
||||
<TableHeader align="right">Tokens</TableHeader>
|
||||
<TableHeader align="right">Cost</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.key}>
|
||||
<TableCell className="text-[13px] break-all">
|
||||
{labels?.[row.key] ?? row.key}
|
||||
</TableCell>
|
||||
<TableCell align="right" className="tabular-nums">
|
||||
{fmtNumber(row.tokens)}
|
||||
</TableCell>
|
||||
<TableCell align="right" className="tabular-nums">
|
||||
{fmtUsd(row.cost)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user spend drill-down. The user detail view carried a single
|
||||
* `tokens_30d` number, which answers neither question an operator has: what is
|
||||
* this person costing, and what is driving it.
|
||||
*/
|
||||
export default function UserUsageModal({
|
||||
userId,
|
||||
onClose,
|
||||
}: {
|
||||
userId: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const token = useSelector(selectToken);
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
const [days, setDays] = useState(30);
|
||||
const [data, setData] = useState<any | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
// Drop the previous range's numbers rather than showing them under the
|
||||
// newly selected one.
|
||||
setData(null);
|
||||
adminService
|
||||
.getUserUsage(userId, { days }, token)
|
||||
.then((res) => res.json())
|
||||
.then((json) => {
|
||||
if (!cancelled) setData(json);
|
||||
})
|
||||
.catch(() => !cancelled && setData({ success: false }))
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId, days, token]);
|
||||
|
||||
const series: Bucket[] = data?.series ?? [];
|
||||
const chartData = useMemo(() => {
|
||||
const colors = usageColors();
|
||||
return {
|
||||
labels: series.map((bucket) => formatDate(bucket.bucket)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Prompt',
|
||||
data: series.map((bucket) => bucket.prompt_tokens),
|
||||
backgroundColor: colors.prompt,
|
||||
},
|
||||
{
|
||||
label: 'Generated',
|
||||
data: series.map((bucket) => bucket.generated_tokens),
|
||||
backgroundColor: colors.generated,
|
||||
},
|
||||
],
|
||||
};
|
||||
// isDarkTheme re-resolves the canvas colors when the theme toggles.
|
||||
}, [series, isDarkTheme]);
|
||||
|
||||
const totals = data?.totals ?? {};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={userId !== null}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title={userId ? `Usage · ${userId}` : 'Usage'}
|
||||
size="lg"
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-1">
|
||||
{RANGES.map((range) => (
|
||||
<Button
|
||||
key={range}
|
||||
variant={range === days ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-3xl"
|
||||
onClick={() => setDays(range)}
|
||||
>
|
||||
{range}d
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : data && !data.success ? (
|
||||
<LoadError message="Failed to load usage." />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<StatCard label="Cost" value={fmtUsd(totals.cost)} />
|
||||
<StatCard label="Tokens" value={fmtNumber(totals.tokens)} />
|
||||
<StatCard label="Calls" value={fmtNumber(totals.calls)} />
|
||||
</div>
|
||||
|
||||
<div className="border-border h-64 rounded-2xl border px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-foreground text-sm font-bold">Daily tokens</p>
|
||||
<div id="admin-user-usage-legend" className="flex" />
|
||||
</div>
|
||||
<div className="relative mt-px h-48 w-full">
|
||||
{series.length === 0 ? (
|
||||
<p className="text-muted-foreground mt-8 text-sm">
|
||||
No usage in this period.
|
||||
</p>
|
||||
) : (
|
||||
<UsageChart
|
||||
data={chartData}
|
||||
legendID="admin-user-usage-legend"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SplitTable title="Model" rows={data?.by_model ?? []} />
|
||||
<SplitTable
|
||||
title="Flow"
|
||||
rows={data?.by_source ?? []}
|
||||
labels={FLOW_LABELS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
fmtRelative,
|
||||
} from './AdminUI';
|
||||
import UserQuotaModal from './UserQuotaModal';
|
||||
import UserUsageModal from './UserUsageModal';
|
||||
|
||||
type AdminUser = {
|
||||
user_id: string;
|
||||
@@ -72,6 +73,7 @@ export default function Users() {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [menuUserId, setMenuUserId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<any | null>(null);
|
||||
const [usageFor, setUsageFor] = useState<string | null>(null);
|
||||
const [quotaUserId, setQuotaUserId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<{
|
||||
ok: boolean;
|
||||
@@ -443,6 +445,7 @@ export default function Users() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<UserUsageModal userId={usageFor} onClose={() => setUsageFor(null)} />
|
||||
<UserQuotaModal
|
||||
userId={quotaUserId}
|
||||
onClose={() => setQuotaUserId(null)}
|
||||
@@ -528,6 +531,19 @@ export default function Users() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Close the detail dialog before the usage dialog opens, so
|
||||
// the two never stack.
|
||||
const userId = detail.user.user_id;
|
||||
setDetail(null);
|
||||
setUsageFor(userId);
|
||||
}}
|
||||
>
|
||||
View spend breakdown
|
||||
</Button>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs">
|
||||
Recent auth events
|
||||
|
||||
@@ -5,7 +5,7 @@ import SectionIndexPage from '../navigation/SectionIndexPage';
|
||||
import { CurrentSectionHeader } from '../navigation/SectionPageHeader';
|
||||
import { ADMIN_SECTION } from '../navigation/sections';
|
||||
import Admins from './Admins';
|
||||
import Audit from './Audit';
|
||||
import Activity from './Activity';
|
||||
import Overview from './Overview';
|
||||
import Quotas from './Quotas';
|
||||
import Usage from './Usage';
|
||||
@@ -38,7 +38,7 @@ export default function Admin() {
|
||||
<Route path="roles" element={<Admins />} />
|
||||
<Route path="usage" element={<Usage />} />
|
||||
<Route path="quotas" element={<Quotas />} />
|
||||
<Route path="audit" element={<Audit />} />
|
||||
<Route path="audit" element={<Activity />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Routes>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildUsageChart,
|
||||
cacheHitRate,
|
||||
type UsageBucket,
|
||||
} from './usageChartData';
|
||||
|
||||
// Chart.js colors resolve against a live DOM; the shapes under test do not.
|
||||
vi.mock('./UsageChart', () => ({
|
||||
seriesColor: (index: number) => `color-${index}`,
|
||||
usageColors: () => ({ prompt: 'prompt-color', generated: 'generated-color' }),
|
||||
}));
|
||||
|
||||
const bucket = (overrides: Partial<UsageBucket>): UsageBucket => ({
|
||||
bucket: '2026-01-01',
|
||||
prompt_tokens: 100,
|
||||
generated_tokens: 20,
|
||||
cost: 1,
|
||||
cached_tokens: null,
|
||||
cache_eligible_prompt_tokens: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('buildUsageChart', () => {
|
||||
it('splits prompt and generated tokens when ungrouped', () => {
|
||||
const chart = buildUsageChart([bucket({})], 'none', 'tokens');
|
||||
expect(chart.datasets.map((d) => d.label)).toEqual(['Prompt', 'Generated']);
|
||||
expect(chart.datasets[0].data).toEqual([100]);
|
||||
});
|
||||
|
||||
it('plots a single series for cost', () => {
|
||||
const chart = buildUsageChart([bucket({ cost: 2.5 })], 'none', 'cost');
|
||||
expect(chart.datasets).toHaveLength(1);
|
||||
expect(chart.datasets[0].data).toEqual([2.5]);
|
||||
});
|
||||
|
||||
it('gives each group its own dataset', () => {
|
||||
const chart = buildUsageChart(
|
||||
[
|
||||
bucket({ group_key: 'gpt-a' }),
|
||||
bucket({ group_key: 'gpt-b', prompt_tokens: 5, generated_tokens: 5 }),
|
||||
],
|
||||
'model',
|
||||
'tokens',
|
||||
);
|
||||
expect(chart.datasets.map((d) => d.label)).toEqual(['gpt-a', 'gpt-b']);
|
||||
expect(chart.datasets[1].data).toEqual([10]);
|
||||
});
|
||||
|
||||
it('pads a group that is missing a bucket so the axes stay aligned', () => {
|
||||
const chart = buildUsageChart(
|
||||
[
|
||||
bucket({ bucket: '2026-01-01', group_key: 'a' }),
|
||||
bucket({ bucket: '2026-01-02', group_key: 'b' }),
|
||||
],
|
||||
'model',
|
||||
'tokens',
|
||||
);
|
||||
expect(chart.labels).toHaveLength(2);
|
||||
// 'a' has no row on the second day: a zero, not a shifted bar.
|
||||
expect(chart.datasets[0].data).toEqual([120, 0]);
|
||||
expect(chart.datasets[1].data).toEqual([0, 120]);
|
||||
});
|
||||
|
||||
it('labels a group with no key rather than dropping the row', () => {
|
||||
const chart = buildUsageChart([bucket({})], 'model', 'tokens');
|
||||
expect(chart.datasets[0].label).toBe('unknown');
|
||||
});
|
||||
|
||||
it('handles an empty series', () => {
|
||||
const chart = buildUsageChart([], 'model', 'cost');
|
||||
expect(chart.labels).toEqual([]);
|
||||
expect(chart.datasets).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheHitRate', () => {
|
||||
it('is unknown when no provider reported cache bins', () => {
|
||||
expect(cacheHitRate([bucket({ cached_tokens: null })])).toBeNull();
|
||||
});
|
||||
|
||||
it('is unknown for an empty series', () => {
|
||||
expect(cacheHitRate([])).toBeNull();
|
||||
});
|
||||
|
||||
it('divides by the prompt tokens of the reporting calls only', () => {
|
||||
// One bucket is one day, and a day mixes calls whose provider reported a
|
||||
// cache breakdown with calls whose provider did not. 50/100, not 50/200.
|
||||
const rate = cacheHitRate([
|
||||
bucket({
|
||||
prompt_tokens: 200,
|
||||
cached_tokens: 50,
|
||||
cache_eligible_prompt_tokens: 100,
|
||||
}),
|
||||
]);
|
||||
expect(rate).toBeCloseTo(50);
|
||||
});
|
||||
|
||||
it('sums across buckets', () => {
|
||||
const rate = cacheHitRate([
|
||||
bucket({
|
||||
prompt_tokens: 100,
|
||||
cached_tokens: 10,
|
||||
cache_eligible_prompt_tokens: 100,
|
||||
}),
|
||||
bucket({
|
||||
prompt_tokens: 100,
|
||||
cached_tokens: 30,
|
||||
cache_eligible_prompt_tokens: 100,
|
||||
}),
|
||||
]);
|
||||
expect(rate).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
it('avoids dividing by zero eligible tokens', () => {
|
||||
expect(
|
||||
cacheHitRate([
|
||||
bucket({ prompt_tokens: 500, cache_eligible_prompt_tokens: 0 }),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { formatDate } from '../utils/dateTimeUtils';
|
||||
import { seriesColor, usageColors } from './UsageChart';
|
||||
|
||||
export type UsageBucket = {
|
||||
bucket: string;
|
||||
prompt_tokens: number;
|
||||
generated_tokens: number;
|
||||
cost: number;
|
||||
cached_tokens: number | null;
|
||||
/** Prompt tokens from the rows that reported a cache breakdown. */
|
||||
cache_eligible_prompt_tokens: number;
|
||||
group_key?: string;
|
||||
};
|
||||
|
||||
export type GroupBy = 'none' | 'model' | 'agent' | 'source';
|
||||
export type Metric = 'tokens' | 'cost';
|
||||
|
||||
export const GROUP_OPTIONS: { value: GroupBy; label: string }[] = [
|
||||
{ value: 'none', label: 'Total' },
|
||||
{ value: 'model', label: 'By model' },
|
||||
{ value: 'agent', label: 'By agent' },
|
||||
{ value: 'source', label: 'By flow' },
|
||||
];
|
||||
|
||||
function valueOf(row: UsageBucket, metric: Metric): number {
|
||||
return metric === 'cost'
|
||||
? row.cost
|
||||
: row.prompt_tokens + row.generated_tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape the admin usage series for the chart.
|
||||
*
|
||||
* Ungrouped gives one dataset per token kind (or a single cost series);
|
||||
* grouped gives one dataset per group key, each aligned to the shared bucket
|
||||
* axis so a group missing a day plots 0 rather than shifting the whole series
|
||||
* left by one bar.
|
||||
*/
|
||||
export function buildUsageChart(
|
||||
series: UsageBucket[],
|
||||
groupBy: GroupBy,
|
||||
metric: Metric,
|
||||
) {
|
||||
const buckets = Array.from(new Set(series.map((row) => row.bucket))).sort();
|
||||
const labels = buckets.map((bucket) => formatDate(bucket));
|
||||
|
||||
if (groupBy === 'none') {
|
||||
if (metric === 'cost') {
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Cost',
|
||||
data: series.map((row) => row.cost),
|
||||
backgroundColor: seriesColor(0),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const colors = usageColors();
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Prompt',
|
||||
data: series.map((row) => row.prompt_tokens),
|
||||
backgroundColor: colors.prompt,
|
||||
},
|
||||
{
|
||||
label: 'Generated',
|
||||
data: series.map((row) => row.generated_tokens),
|
||||
backgroundColor: colors.generated,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const keys = Array.from(
|
||||
new Set(series.map((row) => row.group_key ?? 'unknown')),
|
||||
);
|
||||
return {
|
||||
labels,
|
||||
datasets: keys.map((key, index) => {
|
||||
const byBucket = new Map(
|
||||
series
|
||||
.filter((row) => (row.group_key ?? 'unknown') === key)
|
||||
.map((row) => [row.bucket, valueOf(row, metric)]),
|
||||
);
|
||||
return {
|
||||
label: key,
|
||||
data: buckets.map((bucket) => byBucket.get(bucket) ?? 0),
|
||||
backgroundColor: seriesColor(index),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt-cache hit rate over the calls whose provider reported a breakdown.
|
||||
*
|
||||
* Both numerator and denominator come from the server, because a bucket is a
|
||||
* whole day and mixes reporting with non-reporting calls: filtering *buckets*
|
||||
* here would still divide by the day's entire prompt volume and understate
|
||||
* the rate by however much traffic ran on a provider that reports nothing.
|
||||
*
|
||||
* Returns null when no call in the window reported one.
|
||||
*/
|
||||
export function cacheHitRate(series: UsageBucket[]): number | null {
|
||||
const eligible = series.reduce(
|
||||
(sum, row) => sum + (row.cache_eligible_prompt_tokens ?? 0),
|
||||
0,
|
||||
);
|
||||
if (eligible === 0) return null;
|
||||
const cached = series.reduce((sum, row) => sum + (row.cached_tokens ?? 0), 0);
|
||||
return (cached / eligible) * 100;
|
||||
}
|
||||
@@ -170,10 +170,13 @@ const endpoints = {
|
||||
`/api/admin/users/${encodeURIComponent(id)}/role`,
|
||||
USER_REVOKE_SESSIONS: (id: string) =>
|
||||
`/api/admin/users/${encodeURIComponent(id)}/revoke-sessions`,
|
||||
USER_USAGE: (id: string) =>
|
||||
`/api/admin/users/${encodeURIComponent(id)}/usage`,
|
||||
ADMINS: '/api/admin/admins',
|
||||
USAGE: '/api/admin/usage',
|
||||
AUDIT: '/api/admin/audit',
|
||||
DEVICE_AUDIT: '/api/admin/devices/audit',
|
||||
ACTIVITY: '/api/admin/activity',
|
||||
ACTIVITY_EVENTS: '/api/admin/activity/events',
|
||||
ACTIVITY_EXPORT: '/api/admin/activity/export',
|
||||
QUOTAS: '/api/admin/quotas',
|
||||
QUOTA_INSTANCE: '/api/admin/quotas/instance',
|
||||
QUOTA_TEAM: (id: string) =>
|
||||
|
||||
@@ -1,15 +1,36 @@
|
||||
import apiClient from '../client';
|
||||
import endpoints from '../endpoints';
|
||||
|
||||
const qs = (params: Record<string, string | number | undefined>): string => {
|
||||
type QueryValue = string | number | string[] | undefined;
|
||||
|
||||
const qs = (params: Record<string, QueryValue>): string => {
|
||||
const search = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') search.append(key, String(value));
|
||||
if (value === undefined || value === '') return;
|
||||
// Array facets repeat the key; the API accumulates repeats.
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => item && search.append(key, item));
|
||||
return;
|
||||
}
|
||||
search.append(key, String(value));
|
||||
});
|
||||
const str = search.toString();
|
||||
return str ? `?${str}` : '';
|
||||
};
|
||||
|
||||
export type ActivityFilters = {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
category?: string[];
|
||||
event?: string[];
|
||||
feed?: string[];
|
||||
actor_id?: string;
|
||||
user_id?: string;
|
||||
since?: string;
|
||||
until?: string;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type QuotaScope = 'instance' | 'team' | 'user';
|
||||
|
||||
const quotaUrl = (scope: QuotaScope, subjectId?: string | null): string => {
|
||||
@@ -47,21 +68,25 @@ const adminService = {
|
||||
token: string | null,
|
||||
): Promise<any> =>
|
||||
apiClient.get(`${endpoints.ADMIN.USAGE}${qs(params)}`, token),
|
||||
getAudit: (
|
||||
params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
event?: string;
|
||||
user_id?: string;
|
||||
},
|
||||
getUserUsage: (
|
||||
userId: string,
|
||||
params: { days?: number },
|
||||
token: string | null,
|
||||
): Promise<any> =>
|
||||
apiClient.get(`${endpoints.ADMIN.AUDIT}${qs(params)}`, token),
|
||||
getDeviceAudit: (
|
||||
params: { page?: number; page_size?: number; decision?: string },
|
||||
apiClient.get(`${endpoints.ADMIN.USER_USAGE(userId)}${qs(params)}`, token),
|
||||
getActivity: (filters: ActivityFilters, token: string | null): Promise<any> =>
|
||||
apiClient.get(`${endpoints.ADMIN.ACTIVITY}${qs(filters)}`, token),
|
||||
getActivityEvents: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.ADMIN.ACTIVITY_EVENTS, token),
|
||||
exportActivity: (
|
||||
filters: ActivityFilters,
|
||||
format: 'csv' | 'ndjson',
|
||||
token: string | null,
|
||||
): Promise<any> =>
|
||||
apiClient.get(`${endpoints.ADMIN.DEVICE_AUDIT}${qs(params)}`, token),
|
||||
apiClient.get(
|
||||
`${endpoints.ADMIN.ACTIVITY_EXPORT}${qs({ ...filters, format })}`,
|
||||
token,
|
||||
),
|
||||
getQuotas: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.ADMIN.QUOTAS, token),
|
||||
getUserQuota: (userId: string, token: string | null): Promise<any> =>
|
||||
|
||||
@@ -32,6 +32,8 @@ interface TableCellProps {
|
||||
minWidth?: string;
|
||||
width?: string;
|
||||
align?: 'left' | 'right' | 'center';
|
||||
/** Span several columns, e.g. a detail row under the row it expands. */
|
||||
colSpan?: number;
|
||||
}
|
||||
|
||||
const TableContainer = React.forwardRef<HTMLDivElement, TableContainerProps>(
|
||||
@@ -134,6 +136,7 @@ const TableHeader: React.FC<TableCellProps> = ({
|
||||
minWidth,
|
||||
width,
|
||||
align = 'left',
|
||||
colSpan,
|
||||
}) => {
|
||||
const alignmentClass =
|
||||
align === 'right'
|
||||
@@ -145,6 +148,7 @@ const TableHeader: React.FC<TableCellProps> = ({
|
||||
return (
|
||||
<th
|
||||
data-slot="table-header"
|
||||
colSpan={colSpan}
|
||||
className={cn(
|
||||
'border-border text-muted-foreground relative box-border border-b px-2 py-3 text-sm font-medium lg:px-3',
|
||||
alignmentClass,
|
||||
@@ -164,6 +168,7 @@ const TableCell: React.FC<TableCellProps> = ({
|
||||
minWidth,
|
||||
width,
|
||||
align = 'left',
|
||||
colSpan,
|
||||
}) => {
|
||||
const alignmentClass =
|
||||
align === 'right'
|
||||
@@ -175,6 +180,7 @@ const TableCell: React.FC<TableCellProps> = ({
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
colSpan={colSpan}
|
||||
className={cn(
|
||||
'box-border px-2 py-2 text-sm lg:px-3',
|
||||
alignmentClass,
|
||||
|
||||
@@ -1705,7 +1705,7 @@
|
||||
"admins": "Admins",
|
||||
"usage": "Usage",
|
||||
"quotas": "Quotas",
|
||||
"audit": "Audit"
|
||||
"audit": "Activity"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Endpoint tests for the merged admin activity feed, its filters and export.
|
||||
|
||||
The repository's SQL is covered by
|
||||
tests/storage/db/repositories/test_activity.py; these pin the route wiring,
|
||||
the @admin_required boundary, query-argument parsing and the export framing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from docsgpt.app import app as flask_app
|
||||
|
||||
flask_app.config["TESTING"] = True
|
||||
return flask_app.test_client()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _fake_conn():
|
||||
yield Mock()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _admin(repo: Mock):
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch("docsgpt.app.handle_auth", return_value={"sub": "admin1"})
|
||||
)
|
||||
stack.enter_context(
|
||||
patch("docsgpt.app.resolve_roles", return_value=["admin", "user"])
|
||||
)
|
||||
stack.enter_context(patch("docsgpt.api.admin.activity.db_readonly", _fake_conn))
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"docsgpt.api.admin.activity.ActivityRepository", return_value=repo
|
||||
)
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
def _row(**overrides):
|
||||
base = {
|
||||
"feed": "auth",
|
||||
"id": "1",
|
||||
"event": "oidc_login",
|
||||
"category": "identity",
|
||||
"actor_id": "u1",
|
||||
"target_id": "u1",
|
||||
"ip": "203.0.113.1",
|
||||
"user_agent": "curl/8",
|
||||
"outcome": None,
|
||||
"detail": {"via": "oidc"},
|
||||
"created_at": datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc),
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _repo(rows=None, total=None):
|
||||
repo = Mock()
|
||||
rows = [_row()] if rows is None else rows
|
||||
repo.list.return_value = rows
|
||||
repo.count.return_value = len(rows) if total is None else total
|
||||
repo.iter_all.return_value = iter(rows)
|
||||
repo.event_names.return_value = [
|
||||
{"event": "oidc_login", "category": "identity"},
|
||||
]
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGuard:
|
||||
def test_non_admin_forbidden(self, client):
|
||||
with patch("docsgpt.app.handle_auth", return_value={"sub": "u"}), patch(
|
||||
"docsgpt.app.resolve_roles", return_value=["user"]
|
||||
):
|
||||
assert client.get("/api/admin/activity").status_code == 403
|
||||
assert client.get("/api/admin/activity/export").status_code == 403
|
||||
assert client.get("/api/admin/activity/events").status_code == 403
|
||||
|
||||
def test_unauthenticated(self, client):
|
||||
with patch("docsgpt.app.handle_auth", return_value=None):
|
||||
assert client.get("/api/admin/activity").status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFeed:
|
||||
def test_returns_rows_with_iso_timestamps(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
body = json.loads(client.get("/api/admin/activity").data)
|
||||
assert body["success"] is True
|
||||
assert body["activity"][0]["created_at"] == "2026-01-02T03:04:05+00:00"
|
||||
assert body["total"] == 1
|
||||
assert body["has_more"] is False
|
||||
|
||||
def test_has_more_when_the_page_is_short_of_the_total(self, client):
|
||||
repo = _repo(total=500)
|
||||
with _admin(repo):
|
||||
body = json.loads(client.get("/api/admin/activity").data)
|
||||
assert body["has_more"] is True
|
||||
|
||||
def test_filters_reach_the_repository(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
client.get(
|
||||
"/api/admin/activity?feed=auth,device&category=data"
|
||||
"&event=source.deleted&actor_id=admin1&user_id=u2"
|
||||
"&since=2026-01-01T00:00:00Z&until=2026-02-01&search=handbook"
|
||||
)
|
||||
kwargs = repo.list.call_args.kwargs
|
||||
assert kwargs["feeds"] == ["auth", "device"]
|
||||
assert kwargs["categories"] == ["data"]
|
||||
assert kwargs["events"] == ["source.deleted"]
|
||||
assert kwargs["actor_id"] == "admin1"
|
||||
assert kwargs["user_id"] == "u2"
|
||||
assert kwargs["since"] == datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
# A naive bound is read as UTC.
|
||||
assert kwargs["until"] == datetime(2026, 2, 1, tzinfo=timezone.utc)
|
||||
assert kwargs["search"] == "handbook"
|
||||
|
||||
def test_unknown_facet_is_refused_not_dropped(self, client):
|
||||
"""Dropping it would mean no filter, and no filter means everything.
|
||||
|
||||
A typo or a stale chip must never widen an audit view.
|
||||
"""
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
resp = client.get("/api/admin/activity?category=bogus")
|
||||
assert resp.status_code == 400
|
||||
assert "bogus" in json.loads(resp.data)["message"]
|
||||
repo.list.assert_not_called()
|
||||
|
||||
def test_unknown_feed_is_refused(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
assert client.get("/api/admin/activity?feed=bogus").status_code == 400
|
||||
repo.list.assert_not_called()
|
||||
|
||||
def test_unparseable_time_is_refused(self, client):
|
||||
"""Ignoring it would drop the bound and return the full history."""
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
assert client.get("/api/admin/activity?since=nonsense").status_code == 400
|
||||
repo.list.assert_not_called()
|
||||
|
||||
def test_a_valid_facet_still_passes_through(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
assert client.get("/api/admin/activity?category=data").status_code == 200
|
||||
assert repo.list.call_args.kwargs["categories"] == ["data"]
|
||||
|
||||
def test_repeated_params_accumulate(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
client.get("/api/admin/activity?category=data&category=access")
|
||||
assert repo.list.call_args.kwargs["categories"] == ["data", "access"]
|
||||
|
||||
def test_search_is_length_capped(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
client.get("/api/admin/activity?search=" + "x" * 5000)
|
||||
assert len(repo.list.call_args.kwargs["search"]) == 200
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCatalogue:
|
||||
def test_lists_events_categories_and_feeds(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
body = json.loads(client.get("/api/admin/activity/events").data)
|
||||
assert body["events"] == [{"event": "oidc_login", "category": "identity"}]
|
||||
assert "identity" in body["categories"]
|
||||
assert body["feeds"] == ["auth", "device", "guardrail"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestExport:
|
||||
"""The export streams lazily, so every test consumes it inside the patch."""
|
||||
|
||||
def test_csv_has_a_header_and_one_row_per_event(self, client):
|
||||
repo = _repo(rows=[_row(), _row(id="2", event="pat_created")])
|
||||
with _admin(repo):
|
||||
resp = client.get("/api/admin/activity/export?format=csv")
|
||||
body = resp.get_data(as_text=True)
|
||||
assert resp.status_code == 200
|
||||
assert resp.mimetype == "text/csv"
|
||||
assert "attachment; filename=" in resp.headers["Content-Disposition"]
|
||||
rows = list(csv.DictReader(io.StringIO(body)))
|
||||
assert [row["event"] for row in rows] == ["oidc_login", "pat_created"]
|
||||
# ``detail`` is a JSON object, encoded into the cell.
|
||||
assert json.loads(rows[0]["detail"]) == {"via": "oidc"}
|
||||
|
||||
def test_ndjson_emits_one_object_per_line(self, client):
|
||||
repo = _repo(rows=[_row(), _row(id="2")])
|
||||
with _admin(repo):
|
||||
resp = client.get("/api/admin/activity/export?format=ndjson")
|
||||
body = resp.get_data(as_text=True)
|
||||
assert resp.mimetype == "application/x-ndjson"
|
||||
lines = body.strip().split("\n")
|
||||
assert [json.loads(line)["id"] for line in lines] == ["1", "2"]
|
||||
|
||||
def test_csv_cells_cannot_smuggle_a_spreadsheet_formula(self, client):
|
||||
"""``user_agent`` is an unauthenticated attacker's raw header."""
|
||||
repo = _repo(
|
||||
rows=[_row(user_agent="=cmd|'/c calc'!A0", actor_id="+1", ip="-2")]
|
||||
)
|
||||
with _admin(repo):
|
||||
body = client.get(
|
||||
"/api/admin/activity/export?format=csv"
|
||||
).get_data(as_text=True)
|
||||
row = next(csv.DictReader(io.StringIO(body)))
|
||||
assert row["user_agent"].startswith("'=")
|
||||
assert row["actor_id"].startswith("'+")
|
||||
assert row["ip"].startswith("'-")
|
||||
|
||||
def test_csv_leaves_ordinary_cells_alone(self, client):
|
||||
with _admin(_repo()):
|
||||
body = client.get(
|
||||
"/api/admin/activity/export?format=csv"
|
||||
).get_data(as_text=True)
|
||||
row = next(csv.DictReader(io.StringIO(body)))
|
||||
assert row["user_agent"] == "curl/8"
|
||||
assert row["event"] == "oidc_login"
|
||||
|
||||
def test_empty_csv_export_still_has_its_header(self, client):
|
||||
with _admin(_repo(rows=[])):
|
||||
body = client.get("/api/admin/activity/export").get_data(as_text=True)
|
||||
assert body.strip().startswith("feed,id,event,category")
|
||||
|
||||
def test_export_refuses_an_unknown_facet(self, client):
|
||||
"""Failing open here would stream the whole history to the 100k cap."""
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
resp = client.get("/api/admin/activity/export?category=bogus")
|
||||
resp.get_data()
|
||||
assert resp.status_code == 400
|
||||
repo.iter_all.assert_not_called()
|
||||
|
||||
def test_rejects_an_unknown_format(self, client):
|
||||
with _admin(_repo()):
|
||||
assert (
|
||||
client.get("/api/admin/activity/export?format=xlsx").status_code == 400
|
||||
)
|
||||
|
||||
def test_export_applies_the_same_filters(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
client.get(
|
||||
"/api/admin/activity/export?category=data&search=q"
|
||||
).get_data()
|
||||
kwargs = repo.iter_all.call_args.kwargs
|
||||
assert kwargs["categories"] == ["data"]
|
||||
assert kwargs["search"] == "q"
|
||||
|
||||
def test_row_cap_is_bounded_and_reported(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
resp = client.get("/api/admin/activity/export?max_rows=10")
|
||||
resp.get_data()
|
||||
assert resp.headers["X-Export-Max-Rows"] == "10"
|
||||
assert repo.iter_all.call_args.kwargs["max_rows"] == 10
|
||||
|
||||
def test_an_absurd_cap_clamps_to_the_ceiling(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
resp = client.get("/api/admin/activity/export?max_rows=999999999")
|
||||
resp.get_data()
|
||||
assert resp.headers["X-Export-Max-Rows"] == "100000"
|
||||
|
||||
def test_export_is_not_cached(self, client):
|
||||
with _admin(_repo()):
|
||||
resp = client.get("/api/admin/activity/export")
|
||||
resp.get_data()
|
||||
assert resp.headers["Cache-Control"] == "no-store"
|
||||
@@ -228,14 +228,35 @@ class TestUserLifecycle:
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUsageAndAudit:
|
||||
def test_usage(self, client):
|
||||
@staticmethod
|
||||
def _usage_repos():
|
||||
usage = Mock()
|
||||
usage.bucketed_totals.return_value = [
|
||||
{"bucket": "2026-06-14", "prompt_tokens": 5, "generated_tokens": 3}
|
||||
{
|
||||
"bucket": "2026-06-14",
|
||||
"prompt_tokens": 5,
|
||||
"generated_tokens": 3,
|
||||
"cost": 0.25,
|
||||
"cached_tokens": None,
|
||||
"cache_eligible_prompt_tokens": 0,
|
||||
}
|
||||
]
|
||||
usage.sum_tokens_in_range.return_value = 8
|
||||
stats = Mock()
|
||||
stats.top_token_users.return_value = [{"user_id": "a", "tokens": 8}]
|
||||
stats.top_token_users.return_value = [
|
||||
{"user_id": "a", "tokens": 8, "cost": 0.25}
|
||||
]
|
||||
stats.latency_summary.return_value = {
|
||||
"samples": 2,
|
||||
"p50_ms": 120,
|
||||
"p95_ms": 400,
|
||||
"ttft_samples": 2,
|
||||
"ttft_p50_ms": 30,
|
||||
}
|
||||
return usage, stats
|
||||
|
||||
def test_usage(self, client):
|
||||
usage, stats = self._usage_repos()
|
||||
with _admin(
|
||||
TokenUsageRepository=Mock(return_value=usage),
|
||||
AdminStatsRepository=Mock(return_value=stats),
|
||||
@@ -247,6 +268,66 @@ class TestUsageAndAudit:
|
||||
assert len(data["series"]) == 1
|
||||
assert data["top_users"][0]["user_id"] == "a"
|
||||
|
||||
def test_usage_reports_spend_and_latency(self, client):
|
||||
"""Cost quotas were settable long before spend was visible."""
|
||||
usage, stats = self._usage_repos()
|
||||
with _admin(
|
||||
TokenUsageRepository=Mock(return_value=usage),
|
||||
AdminStatsRepository=Mock(return_value=stats),
|
||||
):
|
||||
data = _body(client.get("/api/admin/usage"))
|
||||
assert data["total_cost"] == 0.25
|
||||
assert data["series"][0]["cost"] == 0.25
|
||||
assert data["latency"]["p95_ms"] == 400
|
||||
assert data["top_users"][0]["cost"] == 0.25
|
||||
|
||||
def test_usage_does_not_run_an_aggregate_nothing_reads(self, client):
|
||||
"""Per-model spend is served by the per-user endpoint and by group_by."""
|
||||
usage, stats = self._usage_repos()
|
||||
with _admin(
|
||||
TokenUsageRepository=Mock(return_value=usage),
|
||||
AdminStatsRepository=Mock(return_value=stats),
|
||||
):
|
||||
data = _body(client.get("/api/admin/usage"))
|
||||
assert "by_model" not in data
|
||||
usage.tokens_by_model.assert_not_called()
|
||||
|
||||
def test_usage_group_by_reaches_the_repository(self, client):
|
||||
usage, stats = self._usage_repos()
|
||||
with _admin(
|
||||
TokenUsageRepository=Mock(return_value=usage),
|
||||
AdminStatsRepository=Mock(return_value=stats),
|
||||
):
|
||||
assert client.get("/api/admin/usage?group_by=model").status_code == 200
|
||||
assert usage.bucketed_totals.call_args.kwargs["group_by"] == "model"
|
||||
|
||||
def test_usage_rejects_an_unknown_grouping(self, client):
|
||||
with _admin():
|
||||
assert client.get("/api/admin/usage?group_by=nonsense").status_code == 400
|
||||
|
||||
def test_per_user_usage_drilldown(self, client):
|
||||
usage, stats = self._usage_repos()
|
||||
stats.user_usage_breakdown.return_value = {
|
||||
"totals": {"tokens": 8, "cost": 0.25, "calls": 1},
|
||||
"by_model": [{"key": "gpt-x", "tokens": 8, "cost": 0.25}],
|
||||
"by_source": [{"key": "agent_stream", "tokens": 8, "cost": 0.25}],
|
||||
}
|
||||
with _admin(
|
||||
TokenUsageRepository=Mock(return_value=usage),
|
||||
AdminStatsRepository=Mock(return_value=stats),
|
||||
):
|
||||
data = _body(client.get("/api/admin/users/u1/usage?days=7"))
|
||||
assert data["totals"]["cost"] == 0.25
|
||||
assert data["by_source"][0]["key"] == "agent_stream"
|
||||
assert len(data["series"]) == 1
|
||||
assert usage.bucketed_totals.call_args.kwargs["user_id"] == "u1"
|
||||
|
||||
def test_per_user_usage_requires_admin(self, client):
|
||||
with patch("docsgpt.app.handle_auth", return_value={"sub": "u1"}), patch(
|
||||
"docsgpt.app.resolve_roles", return_value=["user"]
|
||||
):
|
||||
assert client.get("/api/admin/users/u1/usage").status_code == 403
|
||||
|
||||
def test_usage_invalid_bucket(self, client):
|
||||
with _admin():
|
||||
resp = client.get("/api/admin/usage?bucket=year")
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Unit tests for the shared request-scoped audit helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt.api.audit import record_event
|
||||
from docsgpt.audit_events import ACTIVITY_CATEGORIES, category_for
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
from docsgpt.app import app as flask_app
|
||||
|
||||
flask_app.config["TESTING"] = True
|
||||
return flask_app
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRecordEvent:
|
||||
def test_writes_actor_target_and_request_context(self, app):
|
||||
repo = Mock()
|
||||
conn = MagicMock()
|
||||
with app.test_request_context(
|
||||
"/api/anything",
|
||||
environ_base={"REMOTE_ADDR": "203.0.113.7"},
|
||||
headers={"User-Agent": "curl/8"},
|
||||
):
|
||||
with patch("docsgpt.api.audit.AuthEventsRepository", return_value=repo):
|
||||
record_event(
|
||||
conn, "source.deleted", actor="u1", target=None, source_id="s1"
|
||||
)
|
||||
kwargs = repo.insert.call_args.kwargs
|
||||
assert kwargs["event"] == "source.deleted"
|
||||
assert kwargs["actor_id"] == "u1"
|
||||
assert kwargs["target_id"] is None
|
||||
assert kwargs["ip"] == "203.0.113.7"
|
||||
assert kwargs["user_agent"] == "curl/8"
|
||||
assert kwargs["metadata"] == {"source_id": "s1"}
|
||||
|
||||
def test_drops_none_metadata_values(self, app):
|
||||
repo = Mock()
|
||||
with app.test_request_context("/api/anything"):
|
||||
with patch("docsgpt.api.audit.AuthEventsRepository", return_value=repo):
|
||||
record_event(MagicMock(), "agent.created", actor="u1", name=None, id="a1")
|
||||
assert repo.insert.call_args.kwargs["metadata"] == {"id": "a1"}
|
||||
|
||||
def test_insert_failure_never_raises(self, app):
|
||||
"""An audit write must not be able to fail the action it records."""
|
||||
conn = Mock()
|
||||
conn.begin_nested.side_effect = RuntimeError("boom")
|
||||
with app.test_request_context("/api/anything"):
|
||||
record_event(conn, "source.deleted", actor="u1")
|
||||
|
||||
def test_runs_in_a_savepoint(self, app):
|
||||
"""A poisoned audit insert must roll back alone, not the action."""
|
||||
repo = Mock()
|
||||
conn = MagicMock()
|
||||
with app.test_request_context("/api/anything"):
|
||||
with patch("docsgpt.api.audit.AuthEventsRepository", return_value=repo):
|
||||
record_event(conn, "agent.deleted", actor="u1")
|
||||
conn.begin_nested.assert_called_once()
|
||||
|
||||
def test_works_without_a_request_context(self, app):
|
||||
"""Celery tasks have no request; the row still records."""
|
||||
repo = Mock()
|
||||
with patch("docsgpt.api.audit.AuthEventsRepository", return_value=repo):
|
||||
record_event(MagicMock(), "source.created", actor="u1")
|
||||
kwargs = repo.insert.call_args.kwargs
|
||||
assert kwargs["ip"] is None and kwargs["user_agent"] is None
|
||||
|
||||
def test_anonymous_actor_falls_back(self, app):
|
||||
repo = Mock()
|
||||
with app.test_request_context("/api/anything"):
|
||||
with patch("docsgpt.api.audit.AuthEventsRepository", return_value=repo):
|
||||
record_event(MagicMock(), "agent.created", actor=None)
|
||||
assert repo.insert.call_args.kwargs["actor_id"] == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCategories:
|
||||
@pytest.mark.parametrize(
|
||||
"event,expected",
|
||||
[
|
||||
("oidc_login", "identity"),
|
||||
("oidc_login_denied", "identity"),
|
||||
("pat_created", "identity"),
|
||||
("scim_created", "identity"),
|
||||
("role_granted", "access"),
|
||||
("admin_user_deactivated", "access"),
|
||||
("team.member_add", "access"),
|
||||
("quota_policy_set", "config"),
|
||||
("source.deleted", "data"),
|
||||
("agent.created", "data"),
|
||||
("conversation.deleted", "data"),
|
||||
],
|
||||
)
|
||||
def test_known_events(self, event, expected):
|
||||
assert category_for(event) == expected
|
||||
|
||||
def test_unknown_event_is_other(self):
|
||||
assert category_for("something_new") == "other"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event,expected",
|
||||
[("device.run_command", "device"), ("guardrail.input", "safety")],
|
||||
)
|
||||
def test_merged_feed_namespaces(self, event, expected):
|
||||
assert category_for(event) == expected
|
||||
|
||||
def test_every_produced_category_is_declared(self):
|
||||
assert "other" in ACTIVITY_CATEGORIES
|
||||
for event in (
|
||||
"oidc_login",
|
||||
"role_granted",
|
||||
"quota_policy_set",
|
||||
"source.deleted",
|
||||
"device.run_command",
|
||||
"guardrail.input",
|
||||
"mystery",
|
||||
):
|
||||
assert category_for(event) in ACTIVITY_CATEGORIES
|
||||
@@ -0,0 +1,218 @@
|
||||
"""The data-plane routes must leave an audit trail.
|
||||
|
||||
Identity events were audited from the start; source/agent/conversation
|
||||
mutations were not, so "who deleted that source" had no answer. These pin the
|
||||
hooks at the route layer — the repositories are mocked, what is asserted is
|
||||
that the audit row is written with the acting user and the resource id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from docsgpt.app import app as flask_app
|
||||
|
||||
flask_app.config["TESTING"] = True
|
||||
return flask_app.test_client()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _authed(module: str, **patches):
|
||||
"""Authenticate as ``u1`` and patch names inside ``module``."""
|
||||
recorded: list[tuple] = []
|
||||
|
||||
def _record(_conn, event, *, actor, target=None, **metadata):
|
||||
recorded.append((event, actor, target, metadata))
|
||||
|
||||
@contextmanager
|
||||
def _conn():
|
||||
yield MagicMock()
|
||||
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch("docsgpt.app.handle_auth", return_value={"sub": "u1"})
|
||||
)
|
||||
stack.enter_context(patch("docsgpt.app.resolve_roles", return_value=["user"]))
|
||||
stack.enter_context(patch(f"{module}.db_session", _conn))
|
||||
stack.enter_context(patch(f"{module}.db_readonly", _conn))
|
||||
stack.enter_context(patch(f"{module}.record_event", _record))
|
||||
for name, value in patches.items():
|
||||
stack.enter_context(patch(f"{module}.{name}", value))
|
||||
yield recorded
|
||||
|
||||
|
||||
_SOURCES = "docsgpt.api.user.sources.routes"
|
||||
_AGENTS = "docsgpt.api.user.agents.routes"
|
||||
_CONVERSATIONS = "docsgpt.api.user.conversations.routes"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSourceAudit:
|
||||
def test_delete_records_source_deleted(self, client):
|
||||
repo = Mock()
|
||||
repo.get_any.return_value = {"id": "src-1", "name": "Handbook"}
|
||||
storage = Mock()
|
||||
storage.file_exists.return_value = False
|
||||
with _authed(
|
||||
_SOURCES,
|
||||
SourcesRepository=Mock(return_value=repo),
|
||||
StorageCreator=Mock(get_storage=Mock(return_value=storage)),
|
||||
) as recorded:
|
||||
resp = client.get("/api/delete_old?source_id=src-1")
|
||||
assert resp.status_code == 200
|
||||
assert recorded[0][:3] == ("source.deleted", "u1", None)
|
||||
assert recorded[0][3]["source_id"] == "src-1"
|
||||
assert recorded[0][3]["name"] == "Handbook"
|
||||
|
||||
def test_nothing_recorded_when_the_source_is_missing(self, client):
|
||||
repo = Mock()
|
||||
repo.get_any.return_value = None
|
||||
with _authed(_SOURCES, SourcesRepository=Mock(return_value=repo)) as recorded:
|
||||
resp = client.get("/api/delete_old?source_id=nope")
|
||||
assert resp.status_code == 404
|
||||
assert recorded == []
|
||||
|
||||
|
||||
_UPLOAD = "docsgpt.api.user.sources.upload"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRemoteSourceAudit:
|
||||
"""``source.deleted`` is recorded for remote sources, so creation must be.
|
||||
|
||||
A trail showing a source deleted that was apparently never created is
|
||||
worse than no trail, and the docs state ``source.created`` is recorded.
|
||||
"""
|
||||
|
||||
@contextmanager
|
||||
def _remote_env(self):
|
||||
recorded: list[tuple] = []
|
||||
|
||||
def _record(_conn, event, *, actor, target=None, **metadata):
|
||||
recorded.append((event, actor, target, metadata))
|
||||
|
||||
@contextmanager
|
||||
def _conn():
|
||||
yield MagicMock()
|
||||
|
||||
task = Mock(id="task-9")
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch("docsgpt.app.handle_auth", return_value={"sub": "u1"})
|
||||
)
|
||||
stack.enter_context(
|
||||
patch("docsgpt.app.resolve_roles", return_value=["user"])
|
||||
)
|
||||
stack.enter_context(patch(f"{_UPLOAD}.db_session", _conn))
|
||||
stack.enter_context(patch(f"{_UPLOAD}.record_event", _record))
|
||||
stack.enter_context(
|
||||
patch(
|
||||
f"{_UPLOAD}.ingest_remote",
|
||||
Mock(apply_async=Mock(return_value=task)),
|
||||
)
|
||||
)
|
||||
yield recorded
|
||||
|
||||
def test_url_source_records_source_created(self, client):
|
||||
# The route reads ``request.form``; ``data`` is a JSON string.
|
||||
with self._remote_env() as recorded:
|
||||
resp = client.post(
|
||||
"/api/remote",
|
||||
data={
|
||||
"user": "u1",
|
||||
"source": "url",
|
||||
"name": "Handbook",
|
||||
"data": json.dumps({"url": "https://example.com"}),
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert [row[0] for row in recorded] == ["source.created"]
|
||||
event, actor, target, metadata = recorded[0]
|
||||
assert (actor, target) == ("u1", None)
|
||||
assert metadata["name"] == "Handbook"
|
||||
assert metadata["type"] == "url"
|
||||
assert metadata["source_id"] == json.loads(resp.data)["source_id"]
|
||||
|
||||
def test_github_source_records_source_created(self, client):
|
||||
with self._remote_env() as recorded:
|
||||
resp = client.post(
|
||||
"/api/remote",
|
||||
data={
|
||||
"user": "u1",
|
||||
"source": "github",
|
||||
"name": "Repo",
|
||||
"data": json.dumps({"repo_url": "https://github.com/a/b"}),
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert recorded[0][3]["type"] == "github"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAgentAudit:
|
||||
def test_delete_records_agent_deleted(self, client):
|
||||
repo = Mock()
|
||||
repo.get_any.return_value = {
|
||||
"id": "agent-1",
|
||||
"name": "Support bot",
|
||||
"agent_type": "classic",
|
||||
}
|
||||
with _authed(
|
||||
_AGENTS,
|
||||
AgentsRepository=Mock(return_value=repo),
|
||||
WorkflowsRepository=Mock(),
|
||||
UsersRepository=Mock(),
|
||||
) as recorded:
|
||||
resp = client.delete("/api/delete_agent?id=agent-1")
|
||||
assert resp.status_code == 200
|
||||
assert recorded[0][:3] == ("agent.deleted", "u1", None)
|
||||
assert recorded[0][3]["agent_id"] == "agent-1"
|
||||
assert recorded[0][3]["name"] == "Support bot"
|
||||
|
||||
def test_missing_agent_records_nothing(self, client):
|
||||
repo = Mock()
|
||||
repo.get_any.return_value = None
|
||||
with _authed(_AGENTS, AgentsRepository=Mock(return_value=repo)) as recorded:
|
||||
assert client.delete("/api/delete_agent?id=x").status_code == 404
|
||||
assert recorded == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestConversationAudit:
|
||||
def test_delete_records_conversation_deleted(self, client):
|
||||
repo = Mock()
|
||||
repo.get_any.return_value = {"id": "conv-1"}
|
||||
with _authed(
|
||||
_CONVERSATIONS, ConversationsRepository=Mock(return_value=repo)
|
||||
) as recorded:
|
||||
resp = client.post("/api/delete_conversation?id=conv-1")
|
||||
assert resp.status_code == 200
|
||||
assert recorded[0][:3] == ("conversation.deleted", "u1", None)
|
||||
assert recorded[0][3]["conversation_id"] == "conv-1"
|
||||
|
||||
def test_delete_all_records_the_count(self, client):
|
||||
repo = Mock()
|
||||
repo.delete_all_for_user.return_value = 7
|
||||
with _authed(
|
||||
_CONVERSATIONS, ConversationsRepository=Mock(return_value=repo)
|
||||
) as recorded:
|
||||
resp = client.get("/api/delete_all_conversations")
|
||||
assert resp.status_code == 200
|
||||
assert recorded[0][0] == "conversation.deleted_all"
|
||||
assert recorded[0][3]["deleted"] == 7
|
||||
|
||||
def test_unknown_conversation_records_nothing(self, client):
|
||||
repo = Mock()
|
||||
repo.get_any.return_value = None
|
||||
with _authed(
|
||||
_CONVERSATIONS, ConversationsRepository=Mock(return_value=repo)
|
||||
) as recorded:
|
||||
assert client.post("/api/delete_conversation?id=x").status_code == 200
|
||||
assert recorded == []
|
||||
@@ -167,11 +167,11 @@ class TestPerRoundUsageRows:
|
||||
return messages, None
|
||||
|
||||
rows = []
|
||||
with patch.object(
|
||||
usage_mod,
|
||||
"_persist_call_usage",
|
||||
side_effect=lambda llm_, cu: rows.append(dict(cu)),
|
||||
):
|
||||
|
||||
def _record(llm_, cu, *, duration_ms=None, ttft_ms=None):
|
||||
rows.append({**cu, "duration_ms": duration_ms, "ttft_ms": ttft_ms})
|
||||
|
||||
with patch.object(usage_mod, "_persist_call_usage", side_effect=_record):
|
||||
with patch.object(handler, "handle_tool_calls", fake_tool_calls):
|
||||
first = llm.gen_stream(
|
||||
model="m", messages=[{"role": "user", "content": "hi"}]
|
||||
@@ -185,6 +185,9 @@ class TestPerRoundUsageRows:
|
||||
counts = lambda row: {k: row[k] for k in ("prompt_tokens", "generated_tokens")} # noqa: E731
|
||||
assert counts(rows[0]) == {"prompt_tokens": 100, "generated_tokens": 10}
|
||||
assert counts(rows[1]) == {"prompt_tokens": 200, "generated_tokens": 20}
|
||||
# Each round is timed independently, and each streamed a chunk.
|
||||
assert all(row["duration_ms"] is not None for row in rows)
|
||||
assert all(row["ttft_ms"] is not None for row in rows)
|
||||
|
||||
|
||||
class TestPreferProviderUsageClaim:
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Repository tests for the merged admin activity feed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from docsgpt.storage.db.repositories.activity import (
|
||||
ACTIVITY_COLUMNS,
|
||||
ActivityRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.auth_events import AuthEventsRepository
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def seeded(pg_conn):
|
||||
"""One row in each journal, oldest to newest: guardrail, device, auth."""
|
||||
now = datetime.now(timezone.utc)
|
||||
pg_conn.execute(
|
||||
text(
|
||||
"INSERT INTO guardrail_events "
|
||||
"(user_id, api_key, stage, check_name, detector_type, action, "
|
||||
" outcome, matched_value, created_at) VALUES "
|
||||
"('u-guard', 'sk-secret', 'input', 'pii', 'regex', 'block', "
|
||||
" 'triggered', 'alex@example.com', :ts)"
|
||||
),
|
||||
{"ts": now - timedelta(minutes=10)},
|
||||
)
|
||||
pg_conn.execute(
|
||||
text(
|
||||
"INSERT INTO devices "
|
||||
"(id, user_id, name, machine_pubkey_fingerprint, token_hash, status) "
|
||||
"VALUES ('dev-1', 'u-dev', 'laptop', 'fp-1', 'hash-1', 'active')"
|
||||
)
|
||||
)
|
||||
pg_conn.execute(
|
||||
text(
|
||||
"INSERT INTO device_audit_log "
|
||||
"(device_id, user_id, invocation_id, action, command, "
|
||||
" approval_mode, decision, issued_at, created_at) VALUES "
|
||||
"('dev-1', 'u-dev', 'inv-1', 'run_command', 'ls -la', "
|
||||
" 'ask', 'dispatched', :ts, :ts)"
|
||||
),
|
||||
{"ts": now - timedelta(minutes=5)},
|
||||
)
|
||||
AuthEventsRepository(pg_conn).insert(
|
||||
"victim", "admin_user_deactivated", ip="203.0.113.9",
|
||||
actor_id="admin-1", target_id="victim",
|
||||
)
|
||||
return pg_conn
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repo(seeded):
|
||||
return ActivityRepository(seeded)
|
||||
|
||||
|
||||
class TestMergedFeed:
|
||||
def test_spans_all_three_journals_newest_first(self, repo):
|
||||
rows = repo.list()
|
||||
assert [row["feed"] for row in rows] == ["auth", "device", "guardrail"]
|
||||
assert repo.count() == 3
|
||||
|
||||
def test_every_row_has_the_common_shape(self, repo):
|
||||
for row in repo.list():
|
||||
assert set(row) == set(ACTIVITY_COLUMNS)
|
||||
|
||||
def test_side_journals_get_namespaced_event_names(self, repo):
|
||||
events = {row["feed"]: row["event"] for row in repo.list()}
|
||||
assert events["device"] == "device.run_command"
|
||||
assert events["guardrail"] == "guardrail.input"
|
||||
|
||||
def test_categories_are_derived(self, repo):
|
||||
rows = {row["feed"]: row["category"] for row in repo.list()}
|
||||
assert rows == {
|
||||
"auth": "access",
|
||||
"device": "device",
|
||||
"guardrail": "safety",
|
||||
}
|
||||
|
||||
def test_outcome_carries_the_side_journal_verdict(self, repo):
|
||||
# The values the writers actually produce: guardrails/runtime.py emits
|
||||
# triggered/not_evaluated, remote_device.py emits dispatched.
|
||||
rows = {row["feed"]: row["outcome"] for row in repo.list()}
|
||||
assert rows["device"] == "dispatched"
|
||||
assert rows["guardrail"] == "triggered"
|
||||
assert rows["auth"] is None
|
||||
|
||||
|
||||
class TestSecretExclusion:
|
||||
def test_guardrail_secrets_never_reach_the_feed(self, repo):
|
||||
"""``api_key`` is a raw agent key and ``matched_value`` is source text."""
|
||||
row = next(r for r in repo.list() if r["feed"] == "guardrail")
|
||||
serialized = str(row)
|
||||
assert "sk-secret" not in serialized
|
||||
assert "alex@example.com" not in serialized
|
||||
|
||||
|
||||
class TestFilters:
|
||||
def test_by_feed(self, repo):
|
||||
assert [r["feed"] for r in repo.list(feeds=["device"])] == ["device"]
|
||||
|
||||
def test_by_category(self, repo):
|
||||
rows = repo.list(categories=["safety"])
|
||||
assert [r["feed"] for r in rows] == ["guardrail"]
|
||||
assert repo.count(categories=["safety"]) == 1
|
||||
|
||||
def test_by_category_spanning_auth_and_a_side_journal(self, repo):
|
||||
rows = repo.list(categories=["access", "device"])
|
||||
assert {r["feed"] for r in rows} == {"auth", "device"}
|
||||
|
||||
def test_by_actor(self, repo):
|
||||
assert [r["actor_id"] for r in repo.list(actor_id="admin-1")] == ["admin-1"]
|
||||
|
||||
def test_by_user_matches_actor_or_target(self, repo):
|
||||
assert repo.count(user_id="victim") == 1
|
||||
assert repo.count(user_id="admin-1") == 1
|
||||
|
||||
def test_by_event(self, repo):
|
||||
assert repo.count(events=["device.run_command"]) == 1
|
||||
|
||||
def test_by_time_window(self, repo):
|
||||
now = datetime.now(timezone.utc)
|
||||
assert repo.count(since=now - timedelta(minutes=7)) == 2
|
||||
assert repo.count(until=now - timedelta(minutes=7)) == 1
|
||||
|
||||
def test_search_spans_detail(self, repo):
|
||||
assert repo.count(search="ls -la") == 1
|
||||
assert repo.count(search="203.0.113") == 1
|
||||
assert repo.count(search="nope") == 0
|
||||
|
||||
def test_search_treats_wildcards_literally(self, repo):
|
||||
"""A search box takes a substring; '%' must not match everything."""
|
||||
assert repo.count(search="%") == 0
|
||||
# '_' would otherwise match any single character, e.g. "local".
|
||||
assert repo.count(search="l_cal") == 0
|
||||
|
||||
def test_unknown_feed_yields_nothing(self, repo):
|
||||
assert repo.list(feeds=["made-up"]) == []
|
||||
assert repo.count(feeds=["made-up"]) == 0
|
||||
|
||||
def test_category_no_journal_can_produce(self, repo):
|
||||
assert repo.count(feeds=["device"], categories=["data"]) == 0
|
||||
|
||||
|
||||
class TestPagination:
|
||||
def test_limit_and_offset(self, repo):
|
||||
assert len(repo.list(limit=2)) == 2
|
||||
assert [r["feed"] for r in repo.list(limit=2, offset=2)] == ["guardrail"]
|
||||
|
||||
|
||||
class TestExportStream:
|
||||
def test_iter_all_walks_every_row(self, repo):
|
||||
assert len(list(repo.iter_all(chunk_size=1))) == 3
|
||||
|
||||
def test_iter_all_honours_max_rows(self, repo):
|
||||
assert len(list(repo.iter_all(chunk_size=1, max_rows=2))) == 2
|
||||
|
||||
def test_iter_all_applies_filters(self, repo):
|
||||
rows = list(repo.iter_all(chunk_size=1, feeds=["auth"]))
|
||||
assert [r["feed"] for r in rows] == ["auth"]
|
||||
|
||||
|
||||
class TestCatalogue:
|
||||
def test_event_names_are_paired_with_categories(self, repo):
|
||||
catalogue = repo.event_names()
|
||||
assert {"event": "device.run_command", "category": "device"} in catalogue
|
||||
assert {"event": "admin_user_deactivated", "category": "access"} in catalogue
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Repository tests for admin spend, latency and the per-user drill-down."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from docsgpt.storage.db.repositories.admin_stats import AdminStatsRepository
|
||||
from docsgpt.storage.db.repositories.token_usage import TokenUsageRepository
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _insert(conn, **fields):
|
||||
defaults = {
|
||||
"user_id": "u1",
|
||||
"prompt_tokens": 100,
|
||||
"generated_tokens": 20,
|
||||
"cost": 0.5,
|
||||
"source": "agent_stream",
|
||||
"model_id": "gpt-x",
|
||||
"cached_tokens": None,
|
||||
"duration_ms": None,
|
||||
"ttft_ms": None,
|
||||
"timestamp": datetime.now(timezone.utc),
|
||||
}
|
||||
defaults.update(fields)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO token_usage (user_id, prompt_tokens, generated_tokens, "
|
||||
"cost, source, model_id, cached_tokens, duration_ms, ttft_ms, timestamp) "
|
||||
"VALUES (:user_id, :prompt_tokens, :generated_tokens, :cost, :source, "
|
||||
":model_id, :cached_tokens, :duration_ms, :ttft_ms, :timestamp)"
|
||||
),
|
||||
defaults,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def since():
|
||||
return datetime.now(timezone.utc) - timedelta(days=7)
|
||||
|
||||
|
||||
class TestBucketedCost:
|
||||
def test_buckets_carry_cost_alongside_tokens(self, pg_conn, since):
|
||||
_insert(pg_conn, cost=1.25)
|
||||
_insert(pg_conn, cost=0.75)
|
||||
series = TokenUsageRepository(pg_conn).bucketed_totals(
|
||||
bucket_unit="day", timestamp_gte=since
|
||||
)
|
||||
assert len(series) == 1
|
||||
assert series[0]["cost"] == pytest.approx(2.0)
|
||||
assert series[0]["prompt_tokens"] == 200
|
||||
|
||||
def test_unreported_cache_bins_stay_unknown(self, pg_conn, since):
|
||||
"""NULL means the provider said nothing, which is not the same as 0."""
|
||||
_insert(pg_conn, cached_tokens=None)
|
||||
series = TokenUsageRepository(pg_conn).bucketed_totals(
|
||||
bucket_unit="day", timestamp_gte=since
|
||||
)
|
||||
assert series[0]["cached_tokens"] is None
|
||||
|
||||
def test_reported_cache_bins_sum(self, pg_conn, since):
|
||||
_insert(pg_conn, cached_tokens=30)
|
||||
_insert(pg_conn, cached_tokens=None)
|
||||
series = TokenUsageRepository(pg_conn).bucketed_totals(
|
||||
bucket_unit="day", timestamp_gte=since
|
||||
)
|
||||
assert series[0]["cached_tokens"] == 30
|
||||
|
||||
def test_cache_denominator_counts_only_reporting_rows(self, pg_conn, since):
|
||||
"""A hit rate needs the prompt tokens of the rows that reported.
|
||||
|
||||
Both rows land in the same bucket, so filtering buckets client-side
|
||||
cannot separate them: ``prompt_tokens`` there is the whole day. A rate
|
||||
built on it is understated by however much traffic ran on a provider
|
||||
that reports no cache breakdown.
|
||||
"""
|
||||
_insert(pg_conn, prompt_tokens=100, cached_tokens=50)
|
||||
_insert(pg_conn, prompt_tokens=100, cached_tokens=None)
|
||||
series = TokenUsageRepository(pg_conn).bucketed_totals(
|
||||
bucket_unit="day", timestamp_gte=since
|
||||
)
|
||||
assert series[0]["prompt_tokens"] == 200
|
||||
assert series[0]["cached_tokens"] == 50
|
||||
# 50 / 100 = 50%, not 50 / 200 = 25%.
|
||||
assert series[0]["cache_eligible_prompt_tokens"] == 100
|
||||
|
||||
def test_cache_denominator_is_zero_when_nothing_reported(self, pg_conn, since):
|
||||
_insert(pg_conn, prompt_tokens=100, cached_tokens=None)
|
||||
series = TokenUsageRepository(pg_conn).bucketed_totals(
|
||||
bucket_unit="day", timestamp_gte=since
|
||||
)
|
||||
assert series[0]["cache_eligible_prompt_tokens"] == 0
|
||||
|
||||
def test_grouping_keeps_cost_per_group(self, pg_conn, since):
|
||||
_insert(pg_conn, model_id="cheap", cost=0.1)
|
||||
_insert(pg_conn, model_id="pricey", cost=9.9)
|
||||
series = TokenUsageRepository(pg_conn).bucketed_totals(
|
||||
bucket_unit="day", timestamp_gte=since, group_by="model"
|
||||
)
|
||||
costs = {row["group_key"]: row["cost"] for row in series}
|
||||
assert costs == {"cheap": pytest.approx(0.1), "pricey": pytest.approx(9.9)}
|
||||
|
||||
|
||||
class TestTopUsers:
|
||||
def test_reports_spend_next_to_tokens(self, pg_conn, since):
|
||||
_insert(pg_conn, user_id="heavy", prompt_tokens=1000, cost=2.0)
|
||||
_insert(pg_conn, user_id="light", prompt_tokens=10, cost=0.1)
|
||||
rows = AdminStatsRepository(pg_conn).top_token_users(since=since)
|
||||
assert [row["user_id"] for row in rows] == ["heavy", "light"]
|
||||
assert rows[0]["cost"] == pytest.approx(2.0)
|
||||
|
||||
def test_rollup_rows_are_not_billed_twice(self, pg_conn, since):
|
||||
_insert(pg_conn, user_id="u1", source="agent_stream", cost=1.0)
|
||||
_insert(pg_conn, user_id="u1", source="schedule", cost=1.0)
|
||||
rows = AdminStatsRepository(pg_conn).top_token_users(since=since)
|
||||
assert rows[0]["cost"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
class TestLatencySummary:
|
||||
def test_percentiles_over_measured_calls(self, pg_conn, since):
|
||||
for duration in (100, 200, 300, 400):
|
||||
_insert(pg_conn, duration_ms=duration, ttft_ms=duration // 10)
|
||||
summary = AdminStatsRepository(pg_conn).latency_summary(since=since)
|
||||
assert summary["samples"] == 4
|
||||
assert summary["p50_ms"] == 250
|
||||
assert summary["p95_ms"] == 385
|
||||
assert summary["ttft_p50_ms"] == 25
|
||||
|
||||
def test_unmeasured_rows_are_skipped_not_counted_as_zero(self, pg_conn, since):
|
||||
_insert(pg_conn, duration_ms=1000, ttft_ms=None)
|
||||
_insert(pg_conn, duration_ms=None, ttft_ms=None)
|
||||
summary = AdminStatsRepository(pg_conn).latency_summary(since=since)
|
||||
assert summary["samples"] == 1
|
||||
assert summary["p50_ms"] == 1000
|
||||
# A stream that never yielded contributes no first-token sample.
|
||||
assert summary["ttft_samples"] == 0
|
||||
assert summary["ttft_p50_ms"] is None
|
||||
|
||||
def test_no_data_reports_no_samples(self, pg_conn, since):
|
||||
summary = AdminStatsRepository(pg_conn).latency_summary(since=since)
|
||||
assert summary == {
|
||||
"samples": 0,
|
||||
"p50_ms": None,
|
||||
"p95_ms": None,
|
||||
"ttft_samples": 0,
|
||||
"ttft_p50_ms": None,
|
||||
}
|
||||
|
||||
|
||||
class TestUserUsageBreakdown:
|
||||
def test_totals_and_splits(self, pg_conn, since):
|
||||
_insert(pg_conn, user_id="u1", model_id="a", source="agent_stream", cost=1.0)
|
||||
_insert(pg_conn, user_id="u1", model_id="b", source="webhook", cost=2.0)
|
||||
_insert(pg_conn, user_id="other", model_id="a", cost=99.0)
|
||||
detail = AdminStatsRepository(pg_conn).user_usage_breakdown("u1", since=since)
|
||||
assert detail["totals"]["calls"] == 2
|
||||
assert detail["totals"]["cost"] == pytest.approx(3.0)
|
||||
assert {row["key"] for row in detail["by_model"]} == {"a", "b"}
|
||||
assert {row["key"] for row in detail["by_source"]} == {
|
||||
"agent_stream",
|
||||
"webhook",
|
||||
}
|
||||
|
||||
def test_rollup_rows_are_not_billed_twice(self, pg_conn, since):
|
||||
"""A scheduled run's rollup duplicates its own per-call rows."""
|
||||
_insert(pg_conn, user_id="u1", source="agent_stream", cost=1.0)
|
||||
_insert(pg_conn, user_id="u1", source="schedule", cost=1.0)
|
||||
detail = AdminStatsRepository(pg_conn).user_usage_breakdown("u1", since=since)
|
||||
assert detail["totals"]["calls"] == 1
|
||||
assert detail["totals"]["cost"] == pytest.approx(1.0)
|
||||
assert [row["key"] for row in detail["by_source"]] == ["agent_stream"]
|
||||
|
||||
def test_a_user_with_no_usage_reports_zeroes(self, pg_conn, since):
|
||||
detail = AdminStatsRepository(pg_conn).user_usage_breakdown("ghost", since=since)
|
||||
assert detail["totals"] == {"tokens": 0, "cost": 0.0, "calls": 0}
|
||||
assert detail["by_model"] == [] and detail["by_source"] == []
|
||||
|
||||
def test_window_is_respected(self, pg_conn, since):
|
||||
_insert(
|
||||
pg_conn,
|
||||
user_id="u1",
|
||||
timestamp=datetime.now(timezone.utc) - timedelta(days=30),
|
||||
)
|
||||
detail = AdminStatsRepository(pg_conn).user_usage_breakdown("u1", since=since)
|
||||
assert detail["totals"]["calls"] == 0
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Repository tests for ``auth_events`` (actor/target attribution + feed filters)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt.storage.db.repositories.auth_events import AuthEventsRepository
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repo(pg_conn):
|
||||
return AuthEventsRepository(pg_conn)
|
||||
|
||||
|
||||
class TestInsertAttribution:
|
||||
def test_self_service_event_is_its_own_actor(self, repo):
|
||||
row = repo.insert("u1", "oidc_login")
|
||||
assert row["actor_id"] == "u1"
|
||||
assert row["target_id"] == "u1"
|
||||
assert row["user_id"] == "u1"
|
||||
|
||||
def test_admin_action_separates_actor_from_target(self, repo):
|
||||
row = repo.insert(
|
||||
"victim", "admin_user_deactivated", actor_id="admin-1", target_id="victim"
|
||||
)
|
||||
assert row["actor_id"] == "admin-1"
|
||||
assert row["target_id"] == "victim"
|
||||
# ``user_id`` stays the subject, so the per-user feed is unchanged.
|
||||
assert row["user_id"] == "victim"
|
||||
|
||||
def test_actor_only_event_has_no_target(self, repo):
|
||||
row = repo.insert("admin-1", "team.create", actor_id="admin-1", target_id=None)
|
||||
assert row["actor_id"] == "admin-1"
|
||||
assert row["target_id"] is None
|
||||
|
||||
def test_positional_user_id_still_supported(self, repo):
|
||||
"""Existing call sites pass only ``user_id``; it must keep working."""
|
||||
row = repo.insert("u2", "pat_created", metadata={"token_id": "t"})
|
||||
assert (row["actor_id"], row["target_id"]) == ("u2", "u2")
|
||||
|
||||
|
||||
class TestFeedFilters:
|
||||
"""``/api/admin/audit`` is the single-journal feed; the merged one has its
|
||||
own repository, so the filter set here stays deliberately small."""
|
||||
|
||||
def _seed(self, repo):
|
||||
repo.insert("a", "oidc_login")
|
||||
repo.insert("b", "oidc_login_denied")
|
||||
repo.insert("b", "admin_user_deactivated", actor_id="admin-9", target_id="b")
|
||||
|
||||
def test_filter_by_event(self, repo):
|
||||
self._seed(repo)
|
||||
rows = repo.list_all(event="oidc_login")
|
||||
assert [r["event"] for r in rows] == ["oidc_login"]
|
||||
assert repo.count_all(event="oidc_login") == 1
|
||||
|
||||
def test_filter_by_user_matches_subject_or_target(self, repo):
|
||||
self._seed(repo)
|
||||
assert repo.count_all(user_id="b") == 2
|
||||
|
||||
def test_filter_by_since(self, repo):
|
||||
self._seed(repo)
|
||||
future = datetime.now(timezone.utc) + timedelta(days=1)
|
||||
assert repo.count_all(since=future) == 0
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
assert repo.count_all(since=past) == 3
|
||||
|
||||
def test_pagination(self, repo):
|
||||
self._seed(repo)
|
||||
assert len(repo.list_all(limit=2)) == 2
|
||||
assert len(repo.list_all(limit=2, offset=2)) == 1
|
||||
|
||||
|
||||
class TestPerUserPanel:
|
||||
"""The admin user drill-down is a security panel, not an activity log."""
|
||||
|
||||
def test_data_plane_events_can_be_excluded(self, repo):
|
||||
repo.insert("u1", "oidc_login_denied")
|
||||
for _ in range(5):
|
||||
repo.insert("u1", "conversation.deleted", target_id=None, actor_id="u1")
|
||||
rows = repo.list_recent(
|
||||
"u1", limit=3, exclude_events_like=("source.", "agent.", "conversation.")
|
||||
)
|
||||
# Without the exclusion the five deletes would fill the window and
|
||||
# push the denied login out of it.
|
||||
assert [r["event"] for r in rows] == ["oidc_login_denied"]
|
||||
|
||||
def test_without_exclusions_everything_is_returned(self, repo):
|
||||
repo.insert("u1", "oidc_login")
|
||||
repo.insert("u1", "conversation.deleted", target_id=None, actor_id="u1")
|
||||
assert len(repo.list_recent("u1")) == 2
|
||||
@@ -279,8 +279,12 @@ class TestBucketedTotals:
|
||||
t1 = datetime(2026, 4, 10, 10, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 4, 10, 23, 30, tzinfo=timezone.utc)
|
||||
t3 = datetime(2026, 4, 11, 0, 15, tzinfo=timezone.utc)
|
||||
repo.insert(user_id="u-day", prompt_tokens=10, generated_tokens=5, timestamp=t1)
|
||||
repo.insert(user_id="u-day", prompt_tokens=20, generated_tokens=7, timestamp=t2)
|
||||
repo.insert(
|
||||
user_id="u-day", prompt_tokens=10, generated_tokens=5, cost=0.5, timestamp=t1
|
||||
)
|
||||
repo.insert(
|
||||
user_id="u-day", prompt_tokens=20, generated_tokens=7, cost=0.25, timestamp=t2
|
||||
)
|
||||
repo.insert(user_id="u-day", prompt_tokens=1, generated_tokens=1, timestamp=t3)
|
||||
rows = repo.bucketed_totals(
|
||||
bucket_unit="day",
|
||||
@@ -288,9 +292,25 @@ class TestBucketedTotals:
|
||||
timestamp_gte=datetime(2026, 4, 10, tzinfo=timezone.utc),
|
||||
timestamp_lt=datetime(2026, 4, 12, tzinfo=timezone.utc),
|
||||
)
|
||||
# ``cached_tokens`` is None when no row in the bucket reported a cache
|
||||
# breakdown: "the provider said nothing", not "no cache hits".
|
||||
assert rows == [
|
||||
{"bucket": "2026-04-10", "prompt_tokens": 30, "generated_tokens": 12},
|
||||
{"bucket": "2026-04-11", "prompt_tokens": 1, "generated_tokens": 1},
|
||||
{
|
||||
"bucket": "2026-04-10",
|
||||
"prompt_tokens": 30,
|
||||
"generated_tokens": 12,
|
||||
"cost": 0.75,
|
||||
"cached_tokens": None,
|
||||
"cache_eligible_prompt_tokens": 0,
|
||||
},
|
||||
{
|
||||
"bucket": "2026-04-11",
|
||||
"prompt_tokens": 1,
|
||||
"generated_tokens": 1,
|
||||
"cost": 0.0,
|
||||
"cached_tokens": None,
|
||||
"cache_eligible_prompt_tokens": 0,
|
||||
},
|
||||
]
|
||||
|
||||
def test_hour_bucket(self, pg_conn):
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Migration round-trip test for 0034_auth_events_actor_target."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _alembic_ini() -> Path:
|
||||
return Path(__file__).resolve().parents[3] / "docsgpt" / "alembic.ini"
|
||||
|
||||
|
||||
def _run_alembic(url: str, *args: str) -> None:
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "alembic", "-c", str(_alembic_ini()), *args],
|
||||
timeout=60,
|
||||
env={**os.environ, "POSTGRES_URI": url},
|
||||
)
|
||||
|
||||
|
||||
def _alembic_heads(url: str) -> list[str]:
|
||||
out = subprocess.check_output(
|
||||
[sys.executable, "-m", "alembic", "-c", str(_alembic_ini()), "heads"],
|
||||
timeout=60,
|
||||
env={**os.environ, "POSTGRES_URI": url},
|
||||
text=True,
|
||||
)
|
||||
return [line for line in out.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _alembic_version(conn) -> str:
|
||||
return conn.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
|
||||
|
||||
def _column_exists(conn, table: str, column: str) -> bool:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :t AND column_name = :c AND table_schema = 'public'"
|
||||
),
|
||||
{"t": table, "c": column},
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _index_exists(conn, name: str) -> bool:
|
||||
row = conn.execute(
|
||||
text("SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND indexname = :n"),
|
||||
{"n": name},
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
_0034 = "0034_auth_events_actor_target"
|
||||
_0033 = "0033_quotas"
|
||||
|
||||
|
||||
class TestMigration0034RoundTrip:
|
||||
def test_single_head(self, pg_engine):
|
||||
url = pg_engine.url.render_as_string(hide_password=False)
|
||||
assert len(_alembic_heads(url)) == 1
|
||||
|
||||
def test_head_has_actor_target_columns(self, pg_engine):
|
||||
with pg_engine.connect() as conn:
|
||||
assert _alembic_version(conn) >= _0034
|
||||
assert _column_exists(conn, "auth_events", "actor_id")
|
||||
assert _column_exists(conn, "auth_events", "target_id")
|
||||
|
||||
def test_head_has_feed_indexes(self, pg_engine):
|
||||
"""The global feed orders by created_at with no user filter."""
|
||||
with pg_engine.connect() as conn:
|
||||
assert _index_exists(conn, "auth_events_created_idx")
|
||||
assert _index_exists(conn, "auth_events_event_created_idx")
|
||||
assert _index_exists(conn, "auth_events_actor_idx")
|
||||
|
||||
def test_downgrade_drops_then_upgrade_restores(self, pg_engine):
|
||||
url = pg_engine.url.render_as_string(hide_password=False)
|
||||
_run_alembic(url, "downgrade", _0033)
|
||||
with pg_engine.connect() as conn:
|
||||
assert _alembic_version(conn) == _0033
|
||||
assert not _column_exists(conn, "auth_events", "actor_id")
|
||||
assert not _column_exists(conn, "auth_events", "target_id")
|
||||
assert not _index_exists(conn, "auth_events_created_idx")
|
||||
_run_alembic(url, "upgrade", "head")
|
||||
with pg_engine.connect() as conn:
|
||||
assert _alembic_version(conn) >= _0034
|
||||
assert _column_exists(conn, "auth_events", "actor_id")
|
||||
|
||||
def test_backfill_reads_actor_out_of_metadata(self, pg_engine):
|
||||
"""An admin action filed under its target keeps the acting admin."""
|
||||
url = pg_engine.url.render_as_string(hide_password=False)
|
||||
_run_alembic(url, "downgrade", _0033)
|
||||
with pg_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO auth_events (user_id, event, metadata) VALUES "
|
||||
"('victim', 'admin_user_deactivated', '{\"by\": \"admin-1\"}'::jsonb), "
|
||||
"('grantee', 'role_granted', '{\"granted_by\": \"admin-2\"}'::jsonb), "
|
||||
"('someone', 'oidc_login', '{}'::jsonb), "
|
||||
"('actor-3', 'team.create', '{\"team_id\": \"t1\"}'::jsonb)"
|
||||
)
|
||||
)
|
||||
_run_alembic(url, "upgrade", "head")
|
||||
with pg_engine.connect() as conn:
|
||||
rows = dict(
|
||||
conn.execute(
|
||||
text(
|
||||
"SELECT event, actor_id || '|' || COALESCE(target_id, '-') "
|
||||
"FROM auth_events WHERE user_id IN "
|
||||
"('victim', 'grantee', 'someone', 'actor-3')"
|
||||
)
|
||||
).fetchall()
|
||||
)
|
||||
assert rows["admin_user_deactivated"] == "admin-1|victim"
|
||||
assert rows["role_granted"] == "admin-2|grantee"
|
||||
# Self-service events: the user is both actor and target.
|
||||
assert rows["oidc_login"] == "someone|someone"
|
||||
# Team events were always filed under the actor; they have no user target.
|
||||
assert rows["team.create"] == "actor-3|-"
|
||||
|
||||
def test_legacy_insert_keeps_its_attribution(self, pg_engine):
|
||||
"""A previous-release writer must neither 500 nor lose attribution.
|
||||
|
||||
Its inserts name only the old columns, so ``actor_id`` arrives NULL.
|
||||
The NOT NULL alone would reject them -- and in admin/routes.py that
|
||||
insert shares the request's transaction, so the role grant beside it
|
||||
would roll back too. A bare sentinel would keep the row but throw the
|
||||
actor away, and the highest-volume events are the recoverable ones.
|
||||
"""
|
||||
with pg_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO auth_events (user_id, event, metadata) VALUES "
|
||||
# A login: the user is their own actor and target.
|
||||
"('legacy-login', 'oidc_login', '{}'::jsonb), "
|
||||
# An admin action: the actor is recoverable from metadata.
|
||||
"('legacy-victim', 'admin_user_deactivated', "
|
||||
" '{\"by\": \"admin-legacy\"}'::jsonb), "
|
||||
# A team event: filed under the actor, no user target.
|
||||
"('legacy-actor', 'team.create', '{}'::jsonb)"
|
||||
)
|
||||
)
|
||||
rows = dict(
|
||||
conn.execute(
|
||||
text(
|
||||
"SELECT user_id, actor_id || '|' || COALESCE(target_id, '-') "
|
||||
"FROM auth_events WHERE user_id LIKE 'legacy-%'"
|
||||
)
|
||||
).fetchall()
|
||||
)
|
||||
assert rows["legacy-login"] == "legacy-login|legacy-login"
|
||||
assert rows["legacy-victim"] == "admin-legacy|legacy-victim"
|
||||
assert rows["legacy-actor"] == "legacy-actor|-"
|
||||
|
||||
def test_trigger_leaves_a_current_writer_alone(self, pg_engine):
|
||||
"""A deliberate NULL target must survive.
|
||||
|
||||
``conversation.deleted`` acts on a resource, not an account. Deriving
|
||||
a target there would name the actor as their own target.
|
||||
"""
|
||||
with pg_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO auth_events "
|
||||
"(user_id, actor_id, target_id, event, metadata) "
|
||||
"VALUES ('u9', 'u9', NULL, 'conversation.deleted', '{}'::jsonb)"
|
||||
)
|
||||
)
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT actor_id, target_id FROM auth_events "
|
||||
"WHERE event = 'conversation.deleted'"
|
||||
)
|
||||
).fetchone()
|
||||
assert tuple(row) == ("u9", None)
|
||||
|
||||
def test_backfill_scopes_quota_policy_targets(self, pg_engine):
|
||||
"""Only a user-scoped quota policy has a user target.
|
||||
|
||||
Instance and team policies are filed under the acting admin, so
|
||||
backfilling ``target_id = user_id`` would claim the admin was acted on.
|
||||
"""
|
||||
url = pg_engine.url.render_as_string(hide_password=False)
|
||||
_run_alembic(url, "downgrade", _0033)
|
||||
with pg_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO auth_events (user_id, event, metadata) VALUES "
|
||||
"('admin-q', 'quota_policy_set', "
|
||||
" '{\"by\": \"admin-q\", \"scope\": \"instance\"}'::jsonb), "
|
||||
"('admin-q2', 'quota_policy_deleted', "
|
||||
" '{\"by\": \"admin-q2\", \"scope\": \"team\"}'::jsonb), "
|
||||
"('capped-user', 'quota_policy_set', "
|
||||
" '{\"by\": \"admin-q3\", \"scope\": \"user\"}'::jsonb)"
|
||||
)
|
||||
)
|
||||
_run_alembic(url, "upgrade", "head")
|
||||
with pg_engine.connect() as conn:
|
||||
rows = dict(
|
||||
conn.execute(
|
||||
text(
|
||||
"SELECT user_id, actor_id || '|' || COALESCE(target_id, '-') "
|
||||
"FROM auth_events WHERE user_id IN "
|
||||
"('admin-q', 'admin-q2', 'capped-user')"
|
||||
)
|
||||
).fetchall()
|
||||
)
|
||||
assert rows["admin-q"] == "admin-q|-"
|
||||
assert rows["admin-q2"] == "admin-q2|-"
|
||||
assert rows["capped-user"] == "admin-q3|capped-user"
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Migration round-trip test for 0035_token_usage_latency."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _alembic_ini() -> Path:
|
||||
return Path(__file__).resolve().parents[3] / "docsgpt" / "alembic.ini"
|
||||
|
||||
|
||||
def _run_alembic(url: str, *args: str) -> None:
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "alembic", "-c", str(_alembic_ini()), *args],
|
||||
timeout=60,
|
||||
env={**os.environ, "POSTGRES_URI": url},
|
||||
)
|
||||
|
||||
|
||||
def _alembic_version(conn) -> str:
|
||||
return conn.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
|
||||
|
||||
def _column_exists(conn, table: str, column: str) -> bool:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :t AND column_name = :c AND table_schema = 'public'"
|
||||
),
|
||||
{"t": table, "c": column},
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
_0035 = "0035_token_usage_latency"
|
||||
_0034 = "0034_auth_events_actor_target"
|
||||
|
||||
|
||||
class TestMigration0035RoundTrip:
|
||||
def test_head_has_latency_columns(self, pg_engine):
|
||||
with pg_engine.connect() as conn:
|
||||
assert _alembic_version(conn) >= _0035
|
||||
assert _column_exists(conn, "token_usage", "duration_ms")
|
||||
assert _column_exists(conn, "token_usage", "ttft_ms")
|
||||
|
||||
def test_downgrade_drops_then_upgrade_restores(self, pg_engine):
|
||||
url = pg_engine.url.render_as_string(hide_password=False)
|
||||
_run_alembic(url, "downgrade", _0034)
|
||||
with pg_engine.connect() as conn:
|
||||
assert _alembic_version(conn) == _0034
|
||||
assert not _column_exists(conn, "token_usage", "duration_ms")
|
||||
assert not _column_exists(conn, "token_usage", "ttft_ms")
|
||||
_run_alembic(url, "upgrade", "head")
|
||||
with pg_engine.connect() as conn:
|
||||
assert _column_exists(conn, "token_usage", "duration_ms")
|
||||
|
||||
def test_existing_rows_read_null_latency(self, pg_engine):
|
||||
"""Rows written before 0035 must read NULL, not 0 — a 0ms call is a lie."""
|
||||
url = pg_engine.url.render_as_string(hide_password=False)
|
||||
_run_alembic(url, "downgrade", _0034)
|
||||
with pg_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO token_usage (user_id, prompt_tokens, generated_tokens) "
|
||||
"VALUES ('u-mig35', 10, 1)"
|
||||
)
|
||||
)
|
||||
_run_alembic(url, "upgrade", "head")
|
||||
with pg_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT duration_ms, ttft_ms FROM token_usage "
|
||||
"WHERE user_id = 'u-mig35'"
|
||||
)
|
||||
).fetchone()
|
||||
assert tuple(row) == (None, None)
|
||||
+30
-6
@@ -276,7 +276,11 @@ class TestCreateUser:
|
||||
assert response.headers["Location"].endswith(f"/scim/v2/Users/{USER_PK}")
|
||||
scim_mocks.users.create.assert_called_once_with("alice@example.com", active=True)
|
||||
scim_mocks.audit.insert.assert_called_once_with(
|
||||
"alice@example.com", "scim_created", metadata={"via": "scim"}
|
||||
"alice@example.com",
|
||||
"scim_created",
|
||||
metadata={"via": "scim"},
|
||||
actor_id="system:scim",
|
||||
target_id="alice@example.com",
|
||||
)
|
||||
body = response.get_json()
|
||||
assert body["id"] == USER_PK
|
||||
@@ -373,7 +377,11 @@ class TestReplaceUser:
|
||||
# already lets a fresh login through on a newer iat).
|
||||
scim_mocks.deny_user.assert_not_called()
|
||||
scim_mocks.audit.insert.assert_called_once_with(
|
||||
"alice@example.com", "scim_reactivated", metadata={"via": "scim"}
|
||||
"alice@example.com",
|
||||
"scim_reactivated",
|
||||
metadata={"via": "scim"},
|
||||
actor_id="system:scim",
|
||||
target_id="alice@example.com",
|
||||
)
|
||||
|
||||
def test_put_active_false_triggers_deny(self, client, scim_settings, scim_mocks):
|
||||
@@ -390,7 +398,11 @@ class TestReplaceUser:
|
||||
scim_mocks.users.set_active.assert_called_once_with(USER_PK, False)
|
||||
scim_mocks.deny_user.assert_called_once_with("alice@example.com")
|
||||
scim_mocks.audit.insert.assert_called_once_with(
|
||||
"alice@example.com", "scim_deactivated", metadata={"via": "scim"}
|
||||
"alice@example.com",
|
||||
"scim_deactivated",
|
||||
metadata={"via": "scim"},
|
||||
actor_id="system:scim",
|
||||
target_id="alice@example.com",
|
||||
)
|
||||
|
||||
def test_put_differently_cased_username_deprovisions(self, client, scim_settings, scim_mocks):
|
||||
@@ -445,7 +457,11 @@ class TestPatchUser:
|
||||
scim_mocks.users.set_active.assert_called_once_with(USER_PK, False)
|
||||
scim_mocks.deny_user.assert_called_once_with("alice@example.com")
|
||||
scim_mocks.audit.insert.assert_called_once_with(
|
||||
"alice@example.com", "scim_deactivated", metadata={"via": "scim"}
|
||||
"alice@example.com",
|
||||
"scim_deactivated",
|
||||
metadata={"via": "scim"},
|
||||
actor_id="system:scim",
|
||||
target_id="alice@example.com",
|
||||
)
|
||||
|
||||
def test_replace_with_path_deactivates(self, client, scim_settings, scim_mocks):
|
||||
@@ -479,7 +495,11 @@ class TestPatchUser:
|
||||
scim_mocks.users.set_active.assert_called_once_with(USER_PK, True)
|
||||
scim_mocks.deny_user.assert_not_called()
|
||||
scim_mocks.audit.insert.assert_called_once_with(
|
||||
"alice@example.com", "scim_reactivated", metadata={"via": "scim"}
|
||||
"alice@example.com",
|
||||
"scim_reactivated",
|
||||
metadata={"via": "scim"},
|
||||
actor_id="system:scim",
|
||||
target_id="alice@example.com",
|
||||
)
|
||||
|
||||
def test_bogus_path_rejected(self, client, scim_settings, scim_mocks):
|
||||
@@ -540,7 +560,11 @@ class TestDeleteUser:
|
||||
scim_mocks.users.set_active.assert_called_once_with(USER_PK, False)
|
||||
scim_mocks.deny_user.assert_called_once_with("alice@example.com")
|
||||
scim_mocks.audit.insert.assert_called_once_with(
|
||||
"alice@example.com", "scim_deactivated", metadata={"via": "scim"}
|
||||
"alice@example.com",
|
||||
"scim_deactivated",
|
||||
metadata={"via": "scim"},
|
||||
actor_id="system:scim",
|
||||
target_id="alice@example.com",
|
||||
)
|
||||
|
||||
def test_delete_already_inactive_has_no_side_effects(self, client, scim_settings, scim_mocks):
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Latency is measured by the usage wrappers and persisted with the call.
|
||||
|
||||
``duration_ms`` and ``ttft_ms`` were already computed for the finish log lines
|
||||
and then discarded; these pin that they now reach ``token_usage``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt.usage import gen_token_usage, stream_token_usage
|
||||
|
||||
|
||||
class _LLM:
|
||||
"""Minimal stand-in for an LLM instance the wrappers decorate."""
|
||||
|
||||
def __init__(self):
|
||||
self.token_usage = {"prompt_tokens": 0, "generated_tokens": 0}
|
||||
self.decoded_token = {"sub": "u1"}
|
||||
self.user_api_key = None
|
||||
self.agent_id = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def persisted():
|
||||
calls: list[dict] = []
|
||||
|
||||
def _capture(llm, call_usage, *, duration_ms=None, ttft_ms=None):
|
||||
calls.append({"duration_ms": duration_ms, "ttft_ms": ttft_ms})
|
||||
|
||||
with patch("docsgpt.usage._persist_call_usage", _capture):
|
||||
yield calls
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNonStreaming:
|
||||
def test_records_a_duration_and_no_first_token(self, persisted):
|
||||
@gen_token_usage
|
||||
def _gen(self, model, messages, stream, tools, **kwargs):
|
||||
return "hello"
|
||||
|
||||
assert _gen(_LLM(), "m", [], False, None) == "hello"
|
||||
assert persisted[0]["duration_ms"] >= 0
|
||||
# A non-streaming call has no first-token moment.
|
||||
assert persisted[0]["ttft_ms"] is None
|
||||
|
||||
def test_a_failed_call_still_records_its_duration(self, persisted):
|
||||
@gen_token_usage
|
||||
def _gen(self, model, messages, stream, tools, **kwargs):
|
||||
raise RuntimeError("upstream down")
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
_gen(_LLM(), "m", [], False, None)
|
||||
assert persisted[0]["duration_ms"] >= 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestStreaming:
|
||||
def test_records_time_to_first_chunk(self, persisted):
|
||||
@stream_token_usage
|
||||
def _stream(self, model, messages, stream, tools, **kwargs):
|
||||
yield "a"
|
||||
yield "b"
|
||||
|
||||
assert list(_stream(_LLM(), "m", [], True, None)) == ["a", "b"]
|
||||
row = persisted[0]
|
||||
assert row["ttft_ms"] is not None
|
||||
# First token cannot land after the call finished.
|
||||
assert row["ttft_ms"] <= row["duration_ms"]
|
||||
|
||||
def test_a_stream_that_never_yields_has_no_first_token(self, persisted):
|
||||
"""NULL, not 0 — an instant p50 would be a lie."""
|
||||
|
||||
@stream_token_usage
|
||||
def _stream(self, model, messages, stream, tools, **kwargs):
|
||||
raise RuntimeError("refused")
|
||||
yield # pragma: no cover - unreachable, makes this a generator
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
list(_stream(_LLM(), "m", [], True, None))
|
||||
assert persisted[0]["ttft_ms"] is None
|
||||
assert persisted[0]["duration_ms"] >= 0
|
||||
|
||||
def test_duration_excludes_consumer_backpressure(self, persisted):
|
||||
"""The clock must measure the provider, not a slow reader.
|
||||
|
||||
``stream_token_usage`` is a generator, so every yield suspends until
|
||||
the consumer comes back. Timing start-to-exhaustion would bill the
|
||||
agent loop's tool handling and the SSE client's pace to the model.
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
@stream_token_usage
|
||||
def _stream(self, model, messages, stream, tools, **kwargs):
|
||||
yield "a"
|
||||
yield "b"
|
||||
|
||||
for _ in _stream(_LLM(), "m", [], True, None):
|
||||
# A consumer that takes far longer than the provider did.
|
||||
_time.sleep(0.05)
|
||||
assert persisted[0]["duration_ms"] < 50
|
||||
|
||||
def test_a_stream_cut_short_keeps_the_first_token_it_saw(self, persisted):
|
||||
@stream_token_usage
|
||||
def _stream(self, model, messages, stream, tools, **kwargs):
|
||||
yield "a"
|
||||
raise RuntimeError("dropped")
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
list(_stream(_LLM(), "m", [], True, None))
|
||||
assert persisted[0]["ttft_ms"] is not None
|
||||
Reference in new issue
Block a user