Skip to content

Add AI usage statistics with SQLite storage and Console UI - #96

Draft
ruibaby wants to merge 6 commits into
mainfrom
feat/ai-usage-statistics
Draft

Add AI usage statistics with SQLite storage and Console UI#96
ruibaby wants to merge 6 commits into
mainfrom
feat/ai-usage-statistics

Conversation

@ruibaby

@ruibaby ruibaby commented Aug 11, 2026

Copy link
Copy Markdown
Member

What this PR changes

Adds AI usage statistics for calls made through the audited model wrappers (language, embedding, reranking, image generation). Usage is recorded to a local SQLite database and surfaced through a new Console page.

Statistical model

  • A logical call is one subscribed SDK invocation; an execution is one actual model step (including retries). Calls and executions are recorded separately so retries and failures stay attributable.
  • Records capture input/output tokens (including cache-read, cache-creation, and reasoning subsets), caller plugin, feature, provider, model, operation, status, and timing.
  • usageQuality reports how complete the token data is (REPORTED_COMPONENTS, REPORTED_TOTAL, PARTIAL, ESTIMATED, MISSING); accountedTotalTokens is derived from the best available source.
  • The database never stores prompts, outputs, messages, tool arguments, or raw provider payloads. Errors are reduced to a bounded class name plus optional safe code.

Console API

  • GET /apis/console.api.aifoundation.halo.run/v1alpha1/usage-statistics/summary — totals with filter dimensions and coverage
  • GET .../usage-statistics/trends?resolution=DAY|HOUR — time-bucketed execution trends
  • GET .../usage-statistics/calls?page=...&size=... — paginated call list with filters (status, provider, model, caller, feature, operation, date range)
  • GET .../usage-statistics/calls/{id} — call detail with per-execution records
  • GET .../usage-statistics/health — persistence health (restore status, queue depth, dropped events)
  • POST .../usage-statistics/reset — clear statistics data

Resilience

  • SQLite file lives under %DATA_DIR%; daily rotation uses the same backup scheme as plugin-seo-tools.
  • Corrupt or incomplete databases are isolated and, when a valid backup exists, restored. Persistence failures never break model calls: statistics collection is best-effort and failures are surfaced via the health endpoint and a persistent warning banner in the Console.

Test plan

  • ./gradlew build passes (backend tests included).
  • UI: pnpm lint, pnpm type-check, pnpm test:unit (39 files / 245 tests) pass.
  • End-to-end verified in the dev container via Chrome: real model call → summary counts + tokens updated, call detail with executions, filters, HOUR trends, and health endpoint all confirmed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 90bb8ea3b1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return useInfiniteQuery<UsageCallPage>({
queryKey: [QK_USAGE_CALLS, fingerprint],
queryFn: async ({ pageParam }) => {
const params = buildParams()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reuse the first page's range when loading more calls

With preset ranges such as the default 30d, this recomputes from/to on every infinite-query page request. The backend cursor is bound to a hash of the exact UsageQuery, so a cursor returned by page 1 no longer matches page 2's slightly later range, causing the load-more request to be rejected as an invalid cursor. Keep a stable range for the lifetime of the calls query, or omit explicit dates and let the backend's stable defaults handle cursor paging.

Useful? React with 👍 / 👎.

Comment on lines +90 to +92
service.recordExecution(new UsageExecutionRecord(id, start.id(), start.epoch(), kind,
unitIndex, attemptIndex, startedAt, clock.instant(), status, UsageError.from(error),
start.requestModelId(), responseModelId, normalized));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Mark calls incomplete when execution writes are dropped

When the writer queue is temporarily full or unavailable for an execution event but later accepts the terminal event, this call has no way to tell the session that the execution row was dropped. The session has already added the usage locally, so finish() can persist the logical call as complete with zero missing executions even though the detailed execution evidence is absent; under load this hides per-call data loss in the call table/details. Have recordExecution report submission failure and mark the session incomplete just like a dropped start event.

Useful? React with 👍 / 👎.

@ruibaby
ruibaby force-pushed the feat/ai-usage-statistics branch from e63aad5 to 62bf82e Compare August 12, 2026 04:38
Introduce end-to-end AI usage statistics for Halo admins. This adds runtime usage instrumentation across language, embedding, rerank, and image generation calls, persists logical calls and execution attempts in SQLite with retention and health tracking, exposes summary/trend/history/reset console APIs, regenerates the OpenAPI client, and adds a Chinese console view with filters, charts, call details, and reset flow.
@ruibaby
ruibaby force-pushed the feat/ai-usage-statistics branch from 62bf82e to 62e9002 Compare August 12, 2026 04:39

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 62e9002a27

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +319 to +321
var callDays = rollupDays(writer, rollupBefore,
"call_rollup_frozen_watermark");
var tokenDays = rollupDays(writer, rollupBefore, "rollup_frozen_watermark");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Derive token rollup days from execution timestamps

When an execution crosses UTC midnight from its parent call into a day on which no logical call starts, tokenDays omits that day because rollupDays selects exclusively from ai_calls.started_at_ms. Consequently, rollupTokenDay is never invoked for the execution's day, so full-day summaries and trends stop reporting those tokens as soon as the rollup watermark advances, and execution retention later deletes the only remaining fact permanently. Select token rollup days from execution timestamps as well as fallback call facts.

Useful? React with 👍 / 👎.

Comment on lines +134 to +136
private static void succeedProjection(UsageCallSession session) {
if (session.hasExecutions()) {
session.succeed(NormalizedUsage.missing(), null, 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Finish successful streams that skip the provider

When streaming middleware returns a successful synthetic stream without invoking the provider, this guard prevents the logical session from reaching a terminal state. The repository's RagLanguageModelMiddleware.emptyContextStream does exactly this for skipModel(), and consumers commonly subscribe only to fullStream() or textStream() rather than result(), leaving these calls IN_PROGRESS until restart marks them ABANDONED. Successful completion of an execution-driving projection must terminate the session even when it legitimately has no physical executions.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fc1e3a78d9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

() -> success.accept(session, result)))
.doOnError(error -> UsageTelemetry.safely(
() -> session.fail(error, NormalizedUsage.missing(), failureStepCount)))
.doOnCancel(() -> UsageTelemetry.safely(session::cancel))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Finalize cancellation after execution observers

When a subscriber cancels an active non-streaming call, Reactor invokes this downstream doOnCancel before propagating cancellation to the delegate's UsageExecutionObserver. Consequently, session.cancel() persists the terminal call before the observer records its cancelled execution, so the call is saved with missingExecutionCount == 0 and complete == true even though that execution subsequently reports missing usage. Finalize cancellation after upstream cancellation callbacks have run so the terminal snapshot includes the execution evidence.

Useful? React with 👍 / 👎.

Comment on lines +85 to +87
: usageExecutionObserver.observe(UsageUnitKind.RERANK, 0, invocation,
response -> NormalizedUsage.from(response.getUsage()),
response -> null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Capture the rerank response model for each execution

When a rerank provider returns RerankResponse.response.model, this mapper always supplies null to the execution observer. The logical call later snapshots the same model in AuditedRerankingModel, but the physical execution row permanently loses its available response-model identity, making execution details inconsistent with other model types and less useful for explaining provider behavior.

Useful? React with 👍 / 👎.

@ruibaby
ruibaby marked this pull request as draft August 12, 2026 05:32
@ruibaby
ruibaby requested a review from LIlGG August 12, 2026 10:09
# Conflicts:
#	app/src/main/java/run/halo/aifoundation/service/language/LanguageModelImpl.java
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant