feat(logs): complete modern log metrics and filters (#686)

* feat(logs): complete modern log metrics and filters

* fix(release): use GitHub-hosted runners (#685)

* feat(logs): complete modern log metrics and filters

* fix(logs): validate index ordering and tooltips
This commit is contained in:
tbphp authored and GitHub committed 2026-09-18 10:24:55 +08:00
1 parent 47e3838e0b
commit 37cd99fb01
23 files changed
+572 -34

No files matched your search

@@ -0,0 +1,25 @@
package control
import (
"testing"
"gpt-load/internal/execution"
)
func TestRequestLogQueryFiltersLogicalOperation(t *testing.T) {
t.Parallel()
for _, operation := range []execution.Operation{execution.OperationChatCompletion, execution.OperationWebSearch, execution.OperationResponsesRetrieve} {
query, apiErr := parseRequestLogQuery("operation=" + string(operation))
if apiErr != nil || query.Operation != operation {
t.Fatalf("operation %q parsed as %#v, error = %v", operation, query.Operation, apiErr)
}
}
for _, query := range []string{"operation=unknown", "operation=", "operation=web_search&operation=chat_completion"} {
if _, apiErr := parseRequestLogQuery(query); apiErr == nil {
t.Fatalf("query %q accepted", query)
}
}
if requestLogQueryUsesInternalFields("operation=web_search") {
t.Fatal("logical operation must remain available in self-scoped logs")
}
}
+8 -1
View File
@@ -349,7 +349,7 @@ func parseRequestLogQuery(rawQuery string) (requestlog.ListQuery, *app_errors.AP
allowed := map[string]struct{}{
"from_ms": {}, "to_ms": {}, "group_id": {}, "channel_id": {}, "credential_id": {},
"client_model": {}, "upstream_model": {}, "model_consistency": {}, "access_key_id": {},
"status": {}, "request_id": {}, "protocol": {}, "stream": {}, "final_status_code": {},
"status": {}, "request_id": {}, "protocol": {}, "operation": {}, "stream": {}, "final_status_code": {},
"usage_state": {}, "cost_state": {}, "pricing_completeness": {}, "cache_present": {},
"attempt_status_code": {}, "failure_category": {}, "error_code": {},
"retry_state": {}, "retry_count_min": {}, "retry_count_max": {},
@@ -443,6 +443,13 @@ func parseRequestLogQuery(rawQuery string) (requestlog.ListQuery, *app_errors.AP
}
query.Protocol = parsed
}
if value, ok := singleQueryValue(values, "operation"); ok {
operation := execution.Operation(value)
if !operation.Valid() {
return requestlog.ListQuery{}, app_errors.ErrValidation
}
query.Operation = operation
}
if value, ok := singleQueryValue(values, "stream"); ok {
parsed, valid := parseRequestLogBool(value)
if !valid {
@@ -0,0 +1,31 @@
package requestlog
import (
"context"
"testing"
"time"
"gpt-load/internal/execution"
)
func TestServiceListFiltersLogicalOperationBeforePagination(t *testing.T) {
db := openRequestLogQueryDB(t)
completed := time.Now()
for index, operation := range []execution.Operation{execution.OperationWebSearch, execution.OperationChatCompletion, execution.OperationWebSearch} {
id := []string{"00000000-0000-4000-8000-000000000701", "00000000-0000-4000-8000-000000000702", "00000000-0000-4000-8000-000000000703"}[index]
row := requestLogQueryRow(id, completed, 1, "model", nil)
row.Operation = string(operation)
createRequestLogQueryRow(t, db, row)
}
service := newRequestLogTestService(db)
query := ListQuery{Operation: execution.OperationWebSearch, Limit: 1}
page, err := service.List(context.Background(), query)
if err != nil || len(page.Items) != 1 || page.Items[0].RequestID != "00000000-0000-4000-8000-000000000703" || page.NextCursor == nil {
t.Fatalf("first page = %#v, error = %v", page, err)
}
query.Cursor = page.NextCursor
page, err = service.List(context.Background(), query)
if err != nil || len(page.Items) != 1 || page.Items[0].RequestID != "00000000-0000-4000-8000-000000000701" || page.NextCursor != nil {
t.Fatalf("second page = %#v, error = %v", page, err)
}
}
+3
View File
@@ -55,6 +55,9 @@ func (service *Service) List(ctx context.Context, input ListQuery) (Page, error)
if input.Protocol != "" {
query = query.Where("protocol = ?", input.Protocol)
}
if input.Operation != "" {
query = query.Where("operation = ?", input.Operation)
}
if input.Stream != nil {
query = query.Where("stream = ?", *input.Stream)
}
+1
View File
@@ -82,6 +82,7 @@ type ListQuery struct {
Status telemetry.RequestStatus
RequestID string
Protocol protocol.Protocol
Operation execution.Operation
Stream *bool
FinalStatusCode *int
UsageState usage.State
@@ -123,7 +123,7 @@ func TestExternalDatabaseLifecycle(t *testing.T) {
if err := db.Table("schema_migrations").Order("id").Pluck("id", &migrationIDs).Error; err != nil {
t.Fatalf("read migration ledger: %v", err)
}
if len(migrationIDs) != 16 || migrationIDs[0] != "0001_initial" ||
if len(migrationIDs) != 17 || migrationIDs[0] != "0001_initial" ||
migrationIDs[1] != "0002_access_key_cost_limits" ||
migrationIDs[2] != "0003_remove_observation_fresh_until" ||
migrationIDs[3] != "0004_usage_stats_group_activity_index" ||
@@ -131,8 +131,8 @@ func TestExternalDatabaseLifecycle(t *testing.T) {
migrationIDs[5] != "0006_error_decision" ||
migrationIDs[6] != "0007_access_key_lifecycle" ||
migrationIDs[7] != "0008_remove_inject_usage_options" ||
migrationIDs[8] != "0009_price_multipliers" || migrationIDs[9] != "0010_model_cooldown" || migrationIDs[10] != "0011_custom_access_keys" || migrationIDs[11] != "0012_access_key_mask_prefix" || migrationIDs[12] != "0013_validation_protocol" || migrationIDs[13] != "0014_affinity_kind" || migrationIDs[14] != "0015_group_usage_index" || migrationIDs[15] != "0016_credential_quota_history" {
t.Fatalf("migration ledger = %v, want complete 0001-0016 chain", migrationIDs)
migrationIDs[8] != "0009_price_multipliers" || migrationIDs[9] != "0010_model_cooldown" || migrationIDs[10] != "0011_custom_access_keys" || migrationIDs[11] != "0012_access_key_mask_prefix" || migrationIDs[12] != "0013_validation_protocol" || migrationIDs[13] != "0014_affinity_kind" || migrationIDs[14] != "0015_group_usage_index" || migrationIDs[15] != "0016_credential_quota_history" || migrationIDs[16] != "0017_request_log_operation_index" {
t.Fatalf("migration ledger = %v, want complete 0001-0017 chain", migrationIDs)
}
if !db.Migrator().HasIndex("usage_stats", "idx_usage_stats_group_bucket") {
t.Fatal("usage_stats group activity index is missing")
+1
View File
@@ -673,6 +673,7 @@ func TestAutoMigrateCreatesUsageJournalAndMigrationLedger(t *testing.T) {
"0014_affinity_kind",
"0015_group_usage_index",
"0016_credential_quota_history",
"0017_request_log_operation_index",
}
if !reflect.DeepEqual(migrationIDs, wantMigrationIDs) {
t.Fatalf("schema_migrations IDs = %v, want %v", migrationIDs, wantMigrationIDs)
+1
View File
@@ -107,6 +107,7 @@ var migrations = []migration{
{ID: migrationfiles.ID0014, Up: migrationfiles.Up0014, Validate: migrationfiles.Validate0014, ValidateRecoverable: migrationfiles.ValidateRecoverable0014},
{ID: migrationfiles.ID0015, Up: migrationfiles.Up0015, Validate: migrationfiles.Validate0015, ValidateRecoverable: migrationfiles.ValidateRecoverable0015},
{ID: migrationfiles.ID0016, Up: migrationfiles.Up0016, Validate: migrationfiles.Validate0016, ValidateRecoverable: migrationfiles.ValidateRecoverable0016},
{ID: migrationfiles.ID0017, Up: migrationfiles.Up0017, Validate: migrationfiles.Validate0017, ValidateRecoverable: migrationfiles.ValidateRecoverable0017},
}
func applyMigrations(db *gorm.DB) error {
+1
View File
@@ -28,6 +28,7 @@ func TestMigrationRegistryContainsOrderedMigrations(t *testing.T) {
migrationfiles.ID0014,
migrationfiles.ID0015,
migrationfiles.ID0016,
migrationfiles.ID0017,
}
if len(migrations) != len(wantIDs) {
t.Fatalf("migration registry length = %d, want %d", len(migrations), len(wantIDs))
@@ -0,0 +1,120 @@
package migrations
import (
"fmt"
"gorm.io/gorm"
)
const ID0017 = "0017_request_log_operation_index"
const requestLogOperationIndex0017 = "idx_request_logs_operation_completed_id"
type requestLog0017 struct {
Operation string `gorm:"column:operation;index:idx_request_logs_operation_completed_id,priority:1"`
CompletedAtMS int64 `gorm:"column:completed_at_ms;index:idx_request_logs_operation_completed_id,priority:2,sort:desc"`
ID string `gorm:"column:id;index:idx_request_logs_operation_completed_id,priority:3,sort:desc"`
}
func (requestLog0017) TableName() string { return "request_logs" }
type requestLogOperationIndexColumn0017 struct {
Name string
Descending bool
IsUnique bool
IsValid bool
}
// Up0017 为操作筛选增加与游标顺序一致的索引,不改历史日志。
func Up0017(db *gorm.DB) error {
if err := ValidateRecoverable0017(db); err != nil {
return err
}
if !db.Migrator().HasIndex(&requestLog0017{}, requestLogOperationIndex0017) {
if err := db.Migrator().CreateIndex(&requestLog0017{}, requestLogOperationIndex0017); err != nil {
return fmt.Errorf("create request log operation index: %w", err)
}
}
return Validate0017(db)
}
func ValidateRecoverable0017(db *gorm.DB) error {
if !db.Migrator().HasTable(&requestLog0017{}) {
return fmt.Errorf("request log operation index: request_logs table is missing")
}
if db.Migrator().HasIndex(&requestLog0017{}, requestLogOperationIndex0017) {
return Validate0017(db)
}
return nil
}
func Validate0017(db *gorm.DB) error {
columns, err := requestLogOperationIndexColumns0017(db)
if err != nil {
return err
}
want := []struct {
name string
desc bool
}{
{name: "operation"},
{name: "completed_at_ms", desc: true},
{name: "id", desc: true},
}
if len(columns) != len(want) {
return fmt.Errorf("request log operation index is missing or has an unexpected definition")
}
for position, column := range columns {
if column.Name != want[position].name || column.Descending != want[position].desc || column.IsUnique || !column.IsValid {
return fmt.Errorf("request log operation index has an unexpected definition")
}
}
return nil
}
func requestLogOperationIndexColumns0017(db *gorm.DB) ([]requestLogOperationIndexColumn0017, error) {
var columns []requestLogOperationIndexColumn0017
var query string
var arguments []any
switch db.Dialector.Name() {
case "postgres":
// GORM 的 PostgreSQL GetIndexes 未按 indkey 的位置排序,也不返回排序方向。
query = `
SELECT attribute.attname AS name,
(definition.indoption[key.position - 1] & 1) <> 0 AS descending,
definition.indisunique AS is_unique, definition.indisvalid AS is_valid
FROM pg_index AS definition
JOIN pg_class AS table_relation ON table_relation.oid = definition.indrelid
JOIN pg_namespace AS namespace ON namespace.oid = table_relation.relnamespace
JOIN pg_class AS index_relation ON index_relation.oid = definition.indexrelid
CROSS JOIN LATERAL unnest(definition.indkey) WITH ORDINALITY AS key(attnum, position)
JOIN pg_attribute AS attribute ON attribute.attrelid = table_relation.oid
AND attribute.attnum = key.attnum
WHERE namespace.nspname = current_schema() AND table_relation.relname = 'request_logs'
AND index_relation.relname = ?
ORDER BY key.position`
arguments = []any{requestLogOperationIndex0017}
case "mysql":
query = `
SELECT column_name AS name, collation = 'D' AS descending,
non_unique = 0 AS is_unique, 1 AS is_valid
FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'request_logs' AND index_name = ?
ORDER BY seq_in_index`
arguments = []any{requestLogOperationIndex0017}
case "sqlite":
query = `
SELECT column_info.name AS name, column_info.desc <> 0 AS descending,
index_list."unique" <> 0 AS is_unique, 1 AS is_valid
FROM pragma_index_xinfo(?) AS column_info
JOIN pragma_index_list('request_logs') AS index_list ON index_list.name = ?
WHERE column_info.key = 1
ORDER BY column_info.seqno`
arguments = []any{requestLogOperationIndex0017, requestLogOperationIndex0017}
default:
return nil, fmt.Errorf("request log operation index: unsupported database driver %q", db.Dialector.Name())
}
if err := db.Raw(query, arguments...).Scan(&columns).Error; err != nil {
return nil, fmt.Errorf("read request log operation index: %w", err)
}
return columns, nil
}
@@ -0,0 +1,28 @@
package migrations_test
import (
"testing"
"gpt-load/internal/storage/migrations"
)
func TestRequestLogOperationIndexRejectsMissingTableAndIndex(t *testing.T) {
db := openInitialTestDatabase(t)
if err := migrations.ValidateRecoverable0017(db); err == nil {
t.Fatal("missing request_logs table accepted")
}
if err := migrations.Up0001(db); err != nil {
t.Fatal(err)
}
if err := migrations.Validate0017(db); err == nil {
t.Fatal("missing operation index accepted")
}
for range 2 {
if err := migrations.Up0017(db); err != nil {
t.Fatal(err)
}
if err := migrations.Validate0017(db); err != nil {
t.Fatal(err)
}
}
}
@@ -0,0 +1,98 @@
package storage
import (
"fmt"
"os"
"testing"
"gorm.io/gorm"
)
func TestOperationIndexMigrationContract(t *testing.T) {
testOperationIndexMigration(t, openInternalMigrationTestDatabase)
}
func TestExternalOperationIndexMigrationContract(t *testing.T) {
dsn := os.Getenv("GPT_LOAD_DATABASE_TEST_DSN")
if dsn == "" {
t.Skip("GPT_LOAD_DATABASE_TEST_DSN is not set")
}
testOperationIndexMigration(t, func(t *testing.T) *gorm.DB {
return openExternalIncrementalMigrationDatabase(t, dsn)
})
}
func testOperationIndexMigration(t *testing.T, open func(*testing.T) *gorm.DB) {
t.Helper()
for _, scenario := range []string{"fresh", "existing", "interrupted"} {
t.Run(scenario, func(t *testing.T) {
db := open(t)
if scenario != "fresh" {
if err := applyMigrationRegistry(db, migrations[:len(migrations)-1]); err != nil {
t.Fatal(err)
}
if err := db.Table("request_logs").Create(map[string]any{
"id": "existing", "completed_at_ms": 1, "access_key_id": 1,
"protocol": "openai-responses", "operation": "responses_retrieve",
"client_model": "test", "upstream_model": "test", "status": "error",
"status_code": 500, "duration_ms": 1, "error_summary": "existing diagnostic",
}).Error; err != nil {
t.Fatal(err)
}
if scenario == "interrupted" {
registry := append([]migration(nil), migrations...)
up := registry[len(registry)-1].Up
registry[len(registry)-1].Up = func(tx *gorm.DB) error {
if err := up(tx); err != nil {
return err
}
return fmt.Errorf("interrupt after operation index DDL")
}
if err := applyMigrationRegistry(db, registry); err == nil {
t.Fatal("expected migration interruption")
}
}
}
for range 2 {
if err := AutoMigrate(db); err != nil {
t.Fatal(err)
}
if !db.Migrator().HasIndex("request_logs", "idx_request_logs_operation_completed_id") {
t.Fatal("operation cursor index is missing")
}
if scenario != "fresh" {
var operation string
if err := db.Table("request_logs").Where("id = ?", "existing").Pluck("operation", &operation).Error; err != nil || operation != "responses_retrieve" {
t.Fatalf("existing operation = %q, error = %v", operation, err)
}
}
}
})
}
t.Run("unexpected index definition", func(t *testing.T) {
db := open(t)
if err := applyMigrationRegistry(db, migrations[:len(migrations)-1]); err != nil {
t.Fatal(err)
}
if err := db.Exec("CREATE INDEX idx_request_logs_operation_completed_id ON request_logs (status)").Error; err != nil {
t.Fatal(err)
}
if err := AutoMigrate(db); err == nil {
t.Fatal("unexpected operation index definition accepted")
}
})
t.Run("unexpected index direction", func(t *testing.T) {
db := open(t)
if err := applyMigrationRegistry(db, migrations[:len(migrations)-1]); err != nil {
t.Fatal(err)
}
if err := db.Exec(
"CREATE INDEX idx_request_logs_operation_completed_id ON request_logs (operation, completed_at_ms DESC, id ASC)",
).Error; err != nil {
t.Fatal(err)
}
if err := AutoMigrate(db); err == nil {
t.Fatal("unexpected operation index direction accepted")
}
})
}
+22
View File
@@ -3,6 +3,25 @@ import { ApiError, InvalidResponseError } from '@shared/http/errors'
import { boolean, integer, list, oneOf, record, text } from './response'
export const logStatuses = ['success', 'error', 'incomplete', 'canceled'] as const
export const logOperations = [
'chat_completion',
'responses_create',
'responses_retrieve',
'responses_delete',
'responses_cancel',
'responses_input_items',
'responses_compact',
'responses_input_tokens',
'count_tokens',
'responses_passthrough',
'web_search',
'images_generate',
'images_edit',
'embeddings_create',
'rerank',
'list_models',
'probe',
] as const
export const logFilterNames = [
'from_ms',
'to_ms',
@@ -17,6 +36,7 @@ export const logFilterNames = [
'status',
'request_id',
'protocol',
'operation',
'stream',
'final_status_code',
'usage_state',
@@ -88,6 +108,7 @@ export interface LogEntry {
error_code: string
error_summary: string
affinity_hit: boolean
affinity_kind: string
group_id: number | null
channel_id: string | null
credential_id: number | null
@@ -258,6 +279,7 @@ function entry(value: unknown): LogEntry {
error_code: text(row.error_code),
error_summary: text(row.error_summary),
affinity_hit: boolean(row.affinity_hit),
affinity_kind: text(row.affinity_kind),
group_id: optionalNumber(row.group_id),
channel_id: optionalText(row.channel_id),
credential_id: optionalNumber(row.credential_id),
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed, onScopeDispose } from 'vue'
import { useI18n } from 'vue-i18n'
import { AppCopyValue, AppOverflowText } from '@modern/components/ui'
import { revealCredential } from '@modern/api/credential-actions'
import { useApiClient } from '@shared/http/client-context'
const props = defineProps<{
name: string
groupId: number | null
credentialId: number | null
deleted: boolean
connectionType?: 'api_key' | 'subscription'
}>()
const { t } = useI18n()
const client = useApiClient()
const controller = new AbortController()
onScopeDispose(() => controller.abort())
const display = computed(
() =>
props.name ||
(props.credentialId ? t(props.deleted ? 'logs.deleted' : 'logs.unavailableCredential') : '—'),
)
const canCopy = computed(() =>
Boolean(
!props.deleted &&
props.name &&
(props.connectionType === 'subscription' ||
(props.connectionType === 'api_key' && props.groupId && props.credentialId)),
),
)
function resolveSecret(): Promise<string> {
if (!props.groupId || !props.credentialId) throw new Error('CREDENTIAL_NOT_AVAILABLE')
return revealCredential(client, props.groupId, props.credentialId, controller.signal)
}
</script>
<template>
<AppCopyValue
v-if="canCopy"
:value="name"
:label="t('logs.copyCredential')"
:resolve-value="connectionType === 'api_key' ? resolveSecret : undefined"
/>
<AppOverflowText v-else :text="display" :class="{ 'modern-log-credential-deleted': deleted }" />
</template>
<style scoped>
.modern-log-credential-deleted {
color: var(--modern-muted);
}
</style>
@@ -31,6 +31,7 @@ import { createRedactedLogExport } from './log-redacted-export'
import LogPricingReceipt from './LogPricingReceipt.vue'
import LogValue from './LogValue.vue'
import LogModelWarning from './LogModelWarning.vue'
import LogCredentialValue from './LogCredentialValue.vue'
const props = defineProps<{
id: string
@@ -302,17 +303,18 @@ function resolveRedactedLog(): Promise<string> {
><span>{{ logDuration(attempt.duration_ms, locale) }}</span>
</div>
<div class="modern-log-attempt-route">
<span :class="{ 'is-deleted': attempt.credential_deleted }">{{
attempt.credential_name ||
(attempt.credential_id
? t(
attempt.credential_deleted
? 'logs.deleted'
: 'logs.unavailableCredential',
)
: '—')
}}</span
><AppOverflowText :text="attempt.upstream_model ?? '—'" /><AppBadge
<LogCredentialValue
:name="attempt.credential_name"
:group-id="attempt.group_id"
:credential-id="attempt.credential_id"
:deleted="attempt.credential_deleted"
:connection-type="
(attempt.channel_id
? channels?.get(attempt.channel_id)?.connectionType
: undefined) ?? groups?.get(attempt.group_id)?.connectionType
"
/>
<AppOverflowText :text="attempt.upstream_model ?? '—'" /><AppBadge
v-if="attempt.will_retry"
tone="warning"
variant="soft"
@@ -397,6 +397,14 @@ const loadCredentials = computed(() => {
/>
</div>
</div>
<AppSearchSelect
v-else-if="field.key === 'operation'"
:model-value="draft.operation ?? ''"
:options="options(field)"
:label="t('logs.filters.operation')"
size="xs"
@update:model-value="update('operation', $event)"
/>
<AppSelect
v-else-if="field.kind === 'select'"
:model-value="draft[field.key] ?? ''"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { TriangleAlert } from '@lucide/vue'
import { CircleHelp, TriangleAlert } from '@lucide/vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LogEntry } from '@modern/api/logs'
@@ -9,29 +9,41 @@ import { logModelMismatch } from './log-display'
const props = defineProps<{ row: LogEntry; detail?: boolean }>()
const { t } = useI18n()
const mismatch = computed(() => logModelMismatch(props.row))
const unknown = computed(() => props.row.model_consistency === 'unknown')
const title = computed(() =>
t(mismatch.value ? 'logs.returnedModelMismatch' : 'logs.returnedModelUnknown'),
)
const description = computed(() =>
t('logs.returnedModelMismatchHint', {
requested: props.row.upstream_model,
returned: props.row.upstream_reported_model,
}),
mismatch.value
? t('logs.returnedModelMismatchHint', {
requested: props.row.upstream_model || '—',
returned: props.row.upstream_reported_model || t('logs.modelNotObserved'),
})
: t('logs.returnedModelUnknownHint', { requested: props.row.upstream_model || '—' }),
)
</script>
<template>
<div v-if="detail && mismatch" class="modern-log-model-warning is-detail" role="note">
<AppIcon :icon="TriangleAlert" size="sm" />
<div
v-if="detail && (mismatch || unknown)"
class="modern-log-model-warning is-detail"
:class="{ 'is-unknown': unknown }"
:role="mismatch ? 'alert' : 'note'"
>
<AppIcon :icon="mismatch ? TriangleAlert : CircleHelp" size="sm" />
<div>
<strong>{{ t('logs.returnedModelMismatch') }}</strong>
<strong>{{ title }}</strong>
<p>{{ description }}</p>
</div>
</div>
<AppTooltip v-else-if="mismatch" :label="t('logs.returnedModelMismatch') + '\n' + description">
<AppTooltip v-else-if="mismatch || unknown" :label="title + '\n' + description">
<span
class="modern-log-model-warning"
:class="{ 'is-unknown': unknown }"
tabindex="0"
:aria-label="t('logs.returnedModelMismatch') + '\n' + description"
:aria-label="title + '\n' + description"
>
<AppIcon :icon="TriangleAlert" size="inherit" />
<AppIcon :icon="mismatch ? TriangleAlert : CircleHelp" size="inherit" />
</span>
</AppTooltip>
</template>
@@ -45,9 +57,15 @@ const description = computed(() =>
color: var(--modern-danger);
}
.modern-log-model-warning:focus-visible {
outline: var(--modern-focus-width) solid var(--modern-danger);
outline: var(--modern-focus-width) solid currentColor;
outline-offset: var(--modern-focus-offset);
}
.modern-log-model-warning.is-unknown {
color: var(--modern-info);
}
.modern-log-model-warning.is-detail.is-unknown {
background: var(--modern-info-soft);
}
.modern-log-model-warning.is-detail {
width: 100%;
align-items: flex-start;
@@ -15,7 +15,7 @@ import {
AppTooltip,
} from '@modern/components/ui'
import type { LogColumnId } from './log-columns'
import { logTime } from './log-display'
import { logOutputRate, logTime } from './log-display'
import LogValue from './LogValue.vue'
import LogModelWarning from './LogModelWarning.vue'
import LogFilterLink from './LogFilterLink.vue'
@@ -128,6 +128,8 @@ function fieldFilter(field: LogColumnId): LogQuery | undefined {
return row.status_code ? { final_status_code: String(row.status_code) } : undefined
case 'stream':
return { stream: String(row.stream) }
case 'operation':
return row.operation ? { operation: row.operation } : undefined
case 'usage_state':
return { usage_state: row.usage_state }
case 'cost_state':
@@ -171,6 +173,8 @@ function fieldFilterValue(field: LogColumnId): string {
return t(row.stream ? 'logs.yes' : 'logs.no')
case 'error_code':
return row.error_code
case 'operation':
return row.operation ? t('logs.values.' + row.operation) : '—'
default:
return field
}
@@ -288,6 +292,24 @@ function fieldFilterValue(field: LogColumnId): string {
</div>
</div>
</div>
<div v-else-if="fields.includes('duration_ms')" class="modern-log-cell-stack is-paired">
<div class="modern-log-cell-value">
<LogValue :row="row" column="duration_ms" table />
<template v-if="fields.includes('first_response_ms')">
<span aria-hidden="true">/</span>
<LogValue :row="row" column="first_response_ms" table />
</template>
</div>
<AppTooltip :label="t('logs.outputRate')">
<div
class="modern-log-cell-value modern-log-speed"
tabindex="0"
:aria-label="t('logs.outputRate') + ': ' + logOutputRate(row, locale)"
>
<AppOverflowText :text="logOutputRate(row, locale)" />
</div>
</AppTooltip>
</div>
<div v-else class="modern-log-cell-stack" :class="{ 'is-paired': paired }">
<template v-for="(field, index) in fields" :key="field">
<div
@@ -434,6 +456,11 @@ function fieldFilterValue(field: LogColumnId): string {
.modern-log-cell-value > :last-child {
min-width: 0;
}
.modern-log-speed {
font-family: var(--modern-font-mono);
font-size: var(--modern-font-size-small);
font-variant-numeric: tabular-nums;
}
.modern-log-cell-value.is-model {
font-family: var(--modern-font-mono);
font-size: var(--modern-font-size-small);
@@ -22,9 +22,11 @@ import {
logHasUsage,
logMoney,
logNumber,
logOutputRate,
logStatusTone,
logTime,
} from './log-display'
import LogCredentialValue from './LogCredentialValue.vue'
const props = defineProps<{
row: LogEntry
@@ -44,6 +46,11 @@ const channel = computed(() =>
function valueName(value: string | null | undefined): string {
return !value ? '—' : te('logs.values.' + value) ? t('logs.values.' + value) : value
}
const affinityReason = computed(() => {
if (!props.row.affinity_hit) return ''
const key = 'logs.affinityKinds.' + props.row.affinity_kind
return te(key) ? t(key) : t('logs.affinityKinds.other')
})
const tokenValue = computed(() => {
const row = props.row
if (props.column === 'cache_write_tokens') return logCacheWrites(row)
@@ -176,6 +183,14 @@ const hint = computed(() => {
:value="row.request_id"
:label="t('logs.copyRequest')"
/>
<LogCredentialValue
v-else-if="column === 'credential_name' && !table"
:name="row.credential_name"
:group-id="row.group_id"
:credential-id="row.credential_id"
:deleted="row.credential_deleted"
:connection-type="channel?.connectionType ?? group?.connectionType"
/>
<div v-else-if="column === 'group' && group" class="modern-log-channel">
<AppChannelIcon
v-if="!hideIcon && table"
@@ -200,6 +215,22 @@ const hint = computed(() => {
v-else-if="column === 'protocol' || column === 'upstream_protocol'"
:protocol="row[column]"
/>
<AppTooltip v-else-if="column === 'affinity_hit' && row.affinity_hit" :label="affinityReason">
<span
tabindex="0"
:aria-label="display + ': ' + affinityReason"
:class="{ 'modern-log-boolean': table, 'is-true': row.affinity_hit }"
>{{ display }}</span
>
</AppTooltip>
<span v-else-if="column === 'duration_ms' && !table" class="modern-log-value-stack">
<span>{{ display }}</span>
<AppTooltip :label="t('logs.outputRate')">
<span tabindex="0" :aria-label="t('logs.outputRate') + ': ' + logOutputRate(row, locale)">{{
logOutputRate(row, locale)
}}</span>
</AppTooltip>
</span>
<span
v-else-if="table && (column === 'stream' || column === 'affinity_hit')"
class="modern-log-boolean"
@@ -234,6 +265,12 @@ const hint = computed(() => {
.modern-log-deleted {
color: var(--modern-muted);
}
.modern-log-value-stack {
display: inline-grid;
min-width: 0;
max-width: 100%;
gap: var(--modern-space-0-5);
}
.modern-log-channel {
display: flex;
align-items: center;
@@ -198,7 +198,7 @@ export function useLogColumns(admin: boolean) {
['route_mode', 'affinity_hit', 62],
['status', 'status_code', 76],
['error_code', 'error_summary', 200],
['duration_ms', 'first_response_ms', 72],
['duration_ms', 'first_response_ms', 132],
['input_tokens', 'output_tokens', 68],
['cache_read_tokens', 'cache_hit_rate', 68],
['cache_write_tokens', 'total_tokens', 68],
@@ -9,11 +9,7 @@ export const logStatusTone: Record<LogEntry['status'], SemanticTone> = {
canceled: 'neutral',
}
export function logModelMismatch(row: LogEntry): boolean {
return (
row.model_consistency === 'mismatch' &&
Boolean(row.upstream_model) &&
Boolean(row.upstream_reported_model)
)
return row.model_consistency === 'mismatch'
}
export function logCanMergeError(row: LogEntry): boolean {
// 中断或失败仍可能产生实际消耗;真实的零用量/零费用也不能当成缺失。
@@ -65,6 +61,21 @@ export function logDuration(ms: number | null, locale: string): string {
}).format(ms < 1000 ? ms : ms / 1000) + (ms < 1000 ? ' ms' : ' s')
)
}
export function logOutputRate(row: LogEntry, locale: string): string {
if (
!row.stream ||
!logHasUsage(row) ||
row.first_response_ms === null ||
row.duration_ms <= row.first_response_ms
)
return '—'
const output = Number(row.output_tokens)
if (!Number.isSafeInteger(output) || output <= 0) return '—'
const rate = output / ((row.duration_ms - row.first_response_ms) / 1000)
return Number.isFinite(rate)
? numberFormatter(locale, { maximumFractionDigits: 1 }).format(rate) + ' t/s'
: '—'
}
export function logTime(ms: number, locale: string, full = false): string {
return dateFormatter(locale, {
...(full ? { year: 'numeric' as const } : {}),
@@ -4,6 +4,7 @@ import {
internalLogFilters,
logCursorPattern,
logFilterNames,
logOperations,
logRequestPattern,
logStatuses,
type LogFilterName,
@@ -29,6 +30,7 @@ export interface LogFilterDefinition {
export const logFilterOptions: Partial<Record<LogFilterName, readonly string[]>> = {
status: logStatuses,
protocol: accessProtocols,
operation: logOperations,
stream: ['true', 'false'],
cache_present: ['true', 'false'],
usage_state: ['complete', 'partial', 'missing', 'not_applicable'],
@@ -52,6 +54,7 @@ export const logFilterOptions: Partial<Record<LogFilterName, readonly string[]>>
export const advancedLogFilters: readonly LogFilterDefinition[] = [
{ key: 'request_id', section: 'request', kind: 'text' },
{ key: 'protocol', section: 'request', kind: 'select', values: accessProtocols },
{ key: 'operation', section: 'request', kind: 'select', values: logOperations },
{ key: 'stream', section: 'request', kind: 'select', values: logFilterOptions.stream },
{
key: 'retry_state',
@@ -62,6 +65,7 @@ export const advancedLogFilters: readonly LogFilterDefinition[] = [
},
{ key: 'retry_count_min', section: 'routing', kind: 'number', admin: true },
{ key: 'retry_count_max', section: 'routing', kind: 'number', admin: true },
{ key: 'upstream_model', section: 'routing', kind: 'text', admin: true },
{ key: 'final_status_code', section: 'result', kind: 'number' },
{
key: 'model_consistency',
@@ -9,6 +9,16 @@ export const zhCN = {
modelUpstream: '上游模型',
},
returnedModelMismatch: '返回模型不一致',
returnedModelUnknown: '返回模型未知',
returnedModelUnknownHint: '请求上游模型:{requested}\n上游未返回模型标识,无法确认模型是否一致。',
modelNotObserved: '未观测到',
outputRate: '输出速度',
affinityKinds: {
prompt_prefix: '提示词前缀',
prompt_cache_key: '缓存键',
response_continuity: '响应连续性',
other: '亲和命中',
},
standaloneSearchHint: 'Codex 独立搜索请求',
returnedModelMismatchHint: '请求上游模型:{requested}\n上游返回模型:{returned}',
columnGroups: {
@@ -58,6 +68,7 @@ export const zhCN = {
selectGroupFirst: '先选择分组',
copyRequest: '复制请求 ID',
copyError: '复制错误代码',
copyCredential: '复制凭据',
copyRedactedLog: '复制脱敏日志',
redactedCopySuccess: '已复制脱敏副本,账号、密钥等标识已匿名化,常见敏感内容已处理。',
redactedCopyFailed: '脱敏日志复制失败,请重试。',
@@ -147,6 +158,7 @@ export const zhCN = {
error_summary: '错误详情',
},
filters: {
operation: '操作',
group_id: '分组',
channel_id: '渠道',
credential_id: '账号',
@@ -264,6 +276,7 @@ export const zhCN = {
responses_input_tokens: '输入 Token 统计',
count_tokens: 'Token 统计',
responses_passthrough: '响应透传',
web_search: '独立搜索',
images_generate: '图片生成',
images_edit: '图片编辑',
embeddings_create: '向量生成',
@@ -291,6 +304,17 @@ export const enUS: typeof zhCN = {
modelUpstream: 'Upstream model',
},
returnedModelMismatch: 'Returned model mismatch',
returnedModelUnknown: 'Returned model unknown',
returnedModelUnknownHint:
'Requested upstream model: {requested}\nThe upstream did not report a model identifier, so consistency cannot be verified.',
modelNotObserved: 'Not observed',
outputRate: 'Output speed',
affinityKinds: {
prompt_prefix: 'Prompt prefix',
prompt_cache_key: 'Cache key',
response_continuity: 'Response continuity',
other: 'Affinity match',
},
standaloneSearchHint: 'Codex standalone search request',
returnedModelMismatchHint:
'Requested upstream model: {requested}\nReported upstream model: {returned}',
@@ -342,6 +366,7 @@ export const enUS: typeof zhCN = {
selectGroupFirst: 'Select a group first',
copyRequest: 'Copy request ID',
copyError: 'Copy error code',
copyCredential: 'Copy credential',
copyRedactedLog: 'Copy sanitized log',
redactedCopySuccess:
'Sanitized log copied. Account and key identities are anonymized; common sensitive content has been redacted.',
@@ -433,6 +458,7 @@ export const enUS: typeof zhCN = {
},
filters: {
group_id: 'Group',
operation: 'Operation',
channel_id: 'Channel',
credential_id: 'Account',
access_key_id: 'Access key',
@@ -549,6 +575,7 @@ export const enUS: typeof zhCN = {
responses_input_tokens: 'Input token count',
count_tokens: 'Count tokens',
responses_passthrough: 'Response passthrough',
web_search: 'Standalone search',
images_generate: 'Generate image',
images_edit: 'Edit image',
embeddings_create: 'Create embedding',
@@ -576,6 +603,17 @@ export const jaJP: typeof zhCN = {
modelUpstream: '上流モデル',
},
returnedModelMismatch: '返却モデルが不一致',
returnedModelUnknown: '返却モデルが不明',
returnedModelUnknownHint:
'要求した上流モデル:{requested}\n上流からモデル識別子が返されなかったため、一致を確認できません。',
modelNotObserved: '未観測',
outputRate: '出力速度',
affinityKinds: {
prompt_prefix: 'プロンプト接頭辞',
prompt_cache_key: 'キャッシュキー',
response_continuity: '応答の連続性',
other: 'アフィニティ一致',
},
standaloneSearchHint: 'Codex の独立検索リクエスト',
returnedModelMismatchHint: '要求した上流モデル:{requested}\n上流が返却したモデル:{returned}',
columnGroups: {
@@ -625,6 +663,7 @@ export const jaJP: typeof zhCN = {
selectGroupFirst: '先にグループを選択',
copyRequest: 'リクエスト ID をコピー',
copyError: 'エラーコードをコピー',
copyCredential: '認証情報をコピー',
copyRedactedLog: '匿名化ログをコピー',
redactedCopySuccess:
'匿名化ログをコピーしました。アカウントとキーの識別情報を匿名化し、一般的な機密情報を処理しました。',
@@ -716,6 +755,7 @@ export const jaJP: typeof zhCN = {
},
filters: {
group_id: 'グループ',
operation: '操作',
channel_id: 'チャネル',
credential_id: 'アカウント',
access_key_id: 'アクセスキー',
@@ -832,6 +872,7 @@ export const jaJP: typeof zhCN = {
responses_input_tokens: '入力 Token 数',
count_tokens: 'Token 計数',
responses_passthrough: '応答パススルー',
web_search: '独立検索',
images_generate: '画像生成',
images_edit: '画像編集',
embeddings_create: '埋め込み生成',