Skip to content

Commit 59c9fd0

Browse files
authored
v1.1.21 - Key Hashing & Storage Hardening (#334)
* feat(migrations): add versioned schema migration runner * perf(requestlog): aggregate log stats in SQL and index created_at Replace the paged List()-and-aggregate-in-Go path behind GET /admin/logs/stats with a single SQL aggregation, so counts are exact instead of capped at 5,000 scanned rows. - Add SQLWriter.Stats: one UNION ALL statement groups matching rows by stage, provider, and model in a single round trip. COALESCE(NULLIF(col,''),'unknown') folds NULL and '' into one group, matching the prior Go behavior for the nullable provider/model columns. Totals derive from the NOT NULL stage rows. - Add the idx_request_logs_created_at index, serving List's ordering/range, Delete's range, and Stats' since filter. - Add Stats to the requestlog.Reader interface. - Drop the now-exact response's truncated/scan_limit/available_entries fields and the scan-cap constant. * feat(admin): store API keys hashed at rest Persist sha256(key) plus a display form and look keys up by hash. The full secret is returned only from create and rotate. Existing databases are migrated in place through the new migration runner. The migration rebuilds the table rather than dropping the plaintext column: SQLite refuses to drop a UNIQUE column and retains freed pages until VACUUM, and a Postgres DROP COLUMN leaves the values in the heap. On SQLite the migration also vacuums and truncates the write-ahead log, so the secrets do not survive in the database file. Bootstrap credentials now fail closed. They are accepted only against a key store confirmed empty via IsEmpty; previously a store that could not be read reported zero keys and re-opened them during an outage. The stored display form keeps both ends of the secret. This also fixes the dashboard key table, which truncated an already-truncated value and rendered every key as "fgw_...". * fix(admin): restrict store files before migrating and serialize migrations Restrict SQLite database files to owner-only access immediately after the file exists, rather than after the schema is initialized. SQLite honors the process umask when creating a file, so the key store was previously world-readable while the migration read and rewrote every stored secret. The config and request-log stores shared the ordering. Hold a Postgres advisory lock for the duration of the migration run. Several gateway instances sharing one database could otherwise each see the same pending step, and every instance but the first would fail to start against an already-migrated table. Make the key rebuild's column additions re-runnable, and reject an empty bearer value before it is hashed and looked up. * fix(admin): create store files restricted and check the WAL checkpoint result Create the SQLite database file with owner-only permissions before opening it, rather than relaxing then narrowing it. Chmod-ing after creation left a window in which another process could open the file and keep reading it through the descriptor. Creating it first also gives SQLite's rollback journal and write-ahead log the same restricted mode, since SQLite copies the database file's permissions onto them. Read the result of PRAGMA wal_checkpoint(TRUNCATE) instead of discarding it. The pragma reports a busy database in a row rather than an error and merges nothing, so the key migration could record itself complete with the plaintext still in the write-ahead log. Scope the Postgres column probe to the relation to_regclass resolves. Filtering information_schema by name alone matches a same-named table in any visible schema. Reject an unsupported dialect instead of falling through to the SQLite path, and refuse to run against a database a newer build has migrated. Keep absent request-log dimensions encoding as {} rather than null. * fix(admin): pin plugin storage paths and build the log index concurrently Take each plugin's storage options (dsn, backend) from the running config when a config is submitted over the admin API, and reject a submission that changes one. A request-supplied request-logger dsn otherwise reaches NewSQLiteWriter, which creates a database file — and restricts its permissions — at any path the process can write. Storage location is process configuration, not something an authenticated request may redirect. Rollback re-resolves the same way. Build the request_logs created_at index with CREATE INDEX CONCURRENTLY on Postgres so an existing table's writers are not blocked for the length of the build during a rolling restart, and drop and rebuild an index left invalid by an interrupted concurrent build. SQLite stays inline. Fix the integration key-list assertion for the display-form key, and add Postgres migration coverage: in-place hashing with a rebuilt (not altered) table, idempotence across restarts, concurrent startups serializing on the advisory lock, and fresh-vs-migrated schema parity. Document why hashKey uses SHA-256: the keys are full-entropy CSPRNG tokens, so a password KDF adds per-request cost without defending anything. * docs: note admin storage-path guard and concurrent index in changelog * fix(requestlog): don't drop an index another instance may be building The invalid-index cleanup checked the index by bare name and dropped it whenever it was marked invalid. A CREATE INDEX CONCURRENTLY that is still running is visible as an invalid catalog row, so a second instance starting up could drop an index the first was actively building, and the bare-name probe could match a same-named index in another schema. Resolve the index through to_regclass so the probe honors search_path, and stop auto-dropping: an interrupted build is logged with a REINDEX hint for an operator rather than healed by a cross-instance drop that cannot tell an abandoned index from a live build. This also removes the DROP INDEX string built by concatenation. Also fix the Postgres migration test teardown to run on context.Background(), since t.Context() is already canceled by the time t.Cleanup runs; trim the hashKey godoc to the house style and keep the rationale as an inline comment; assert the NoTx failure wraps its error by identity; and add a rollback regression test proving a poisoned history entry cannot redirect plugin storage. * docs(requestlog): keep index comments neutral and accurate Drop an internal marker from a code comment and correct the build-failure log message: a failed concurrent index build leaves an invalid index that a later start reports and points at REINDEX, rather than silently rebuilding, so the message no longer implies automatic recovery. * refactor(logger): record through the shared request-log store The request-logger plugin opened its own SQLite/Postgres store from its plugin config, while the gateway separately built a request-log store from REQUEST_LOG_STORE_* for the admin log views. The two were never connected, so unless an operator pointed both at the same path the admin API read a different database than the plugin wrote — and a request body submitted to POST /admin/config could set the plugin dsn, creating a file at an arbitrary path on reload. The gateway now holds the request-log store and injects it into logging plugins as they load, mirroring SetObservability. The plugin records through the shared store and never opens one from config, so no request-supplied value reaches the filesystem. Its backend/dsn options are obsolete and ignored with a warning; persistence is configured once via REQUEST_LOG_STORE_BACKEND / REQUEST_LOG_STORE_DSN. This deletes the resolveStorageOptions guard and its rollback re-resolve — with no storage settings in the reloadable config, there is nothing to pin. The plugin's Close is now a no-op: the store is owned by the gateway and closed on shutdown, and closing it from the plugin would break the admin log reader that shares it.
1 parent 87fe4bd commit 59c9fd0

32 files changed

Lines changed: 2915 additions & 355 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ ai-gateway/
9191
│ ├── discovery/ # Shared OpenAI-compatible model discovery helper
9292
│ ├── latency/ # Latency tracking for least-latency strategy
9393
│ ├── metrics/ # Prometheus metrics
94+
│ ├── migrations/ # Versioned schema-migration runner (schema_migrations ledger)
9495
│ ├── otel/ # OTel-backed observability.Provider: OTLP exporter, W3C propagation, trace-ID unifying IDGenerator, privacy-aware span errors, HTTP middleware
9596
│ ├── redact/ # Error-message redaction policies (email / JWT / AWS key)
9697
│ ├── plugins/ # Built-in plugin implementations

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,38 @@ All notable changes to Ferro Labs AI Gateway are documented here.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.1.21] — 2026-07-10
9+
10+
Key hashing and storage hardening — the fourth phase of the v1.1.x hardening release line. The Go API is unchanged; two admin endpoints change their response values (see **Changed**).
11+
12+
### Security
13+
14+
- **API keys are now stored hashed at rest.** The gateway persists `sha256(key)` plus a display form, and looks a key up by its hash. The full secret is returned exactly once, from `POST /admin/keys` and `POST /admin/keys/{id}/rotate`, and cannot be recovered afterwards. Existing databases are migrated in place on first start.
15+
- The migration **rebuilds the key table** rather than dropping the plaintext column, so the secrets are removed from the database file itself. Dropping the column would leave them readable on disk: SQLite retains freed pages until `VACUUM`, and Postgres `DROP COLUMN` only updates the catalog. On SQLite the migration additionally vacuums and truncates the write-ahead log.
16+
- Operators who need a hard guarantee that no earlier copy of a key survives — in backups, WAL archives, replicas, or unlinked filesystem blocks — should **rotate their keys after upgrading**. No schema migration can reach those copies.
17+
- **Bootstrap credentials now fail closed.** `ADMIN_BOOTSTRAP_KEY` and `ADMIN_BOOTSTRAP_READ_ONLY_KEY` are accepted only against a key store that is confirmed empty. Previously a key store that could not be read reported zero keys, which re-opened the bootstrap credentials during a database outage.
18+
- **SQLite database files are restricted to owner-only access before any data is written to them**, rather than after the schema is initialized. SQLite creates files honoring the process umask, so a database could previously be world-readable for the duration of startup.
19+
- An `Authorization: Bearer` header with an empty value is no longer matched against the key store.
20+
- **The request-logger plugin no longer opens its own database from plugin config.** It records through the shared request-log store the gateway builds from `REQUEST_LOG_STORE_BACKEND` / `REQUEST_LOG_STORE_DSN`, so no request-supplied value reaches the filesystem. This removes a path where a config submitted over `POST/PUT /admin/config` could create a file at an arbitrary location.
21+
22+
### Added
23+
24+
- **Versioned schema migrations.** Schema changes now run through a `schema_migrations` ledger, replacing repeated `ALTER TABLE` statements whose failures were classified by matching the error message text. Databases created by earlier releases are adopted at the baseline version rather than re-initialized. On Postgres the runner holds an advisory lock for its duration, so several gateway instances sharing one database can start at the same time.
25+
26+
### Changed
27+
28+
- `GET /admin/keys`, `/admin/keys/{id}` and `/admin/keys/usage` return the `key` field as `fgw_ab12...cd34`, keeping both ends of the secret so an operator can match a key they hold against a listed record. It was previously truncated to a leading fragment.
29+
- `GET /admin/logs/stats` computes its aggregates in the database instead of scanning up to 5,000 rows into memory, so its counts are now exact for any number of matching entries. The `truncated`, `scan_limit`, and `available_entries` summary fields described the old scan cap and have been removed.
30+
- **The request-logger plugin's `backend` and `dsn` options are obsolete and ignored** (with a startup warning). Persistence now targets the shared request-log store; configure it with `REQUEST_LOG_STORE_BACKEND` and `REQUEST_LOG_STORE_DSN`. A deployment that previously set only the plugin's `dsn` should move that value to `REQUEST_LOG_STORE_DSN`. This also fixes a case where the admin log views read a different database than the plugin wrote to.
31+
32+
### Fixed
33+
34+
- The admin dashboard's key table showed every key as `fgw_...` because it truncated a value the server had already truncated. Keys are now distinguishable from one another.
35+
- `request_logs` gains an index on `created_at`, which serves the log listing's ordering, the retention delete, and the stats time filter. On Postgres it is built with `CREATE INDEX CONCURRENTLY` so an existing table's writers are not blocked during a rolling restart.
36+
- The bootstrap-key check no longer loads every API key on unauthenticated admin requests.
37+
38+
---
39+
840
## [1.1.20] — 2026-07-09
941

1042
Streaming deadlines and serving robustness — the third phase of the v1.1.x hardening release line. All changes are additive/behavior-preserving for the public API.

config.example.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,7 @@
181181
"enabled": true,
182182
"config": {
183183
"level": "info",
184-
"persist": false,
185-
"backend": "sqlite",
186-
"dsn": "ferrogw-requests.db"
184+
"persist": false
187185
}
188186
},
189187
{

config.example.yaml

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,11 @@ plugins:
138138
enabled: true
139139
config:
140140
level: info
141-
# Optional persistent request log storage.
142-
# When disabled, logs are emitted only to stdout.
141+
# Persist each request to the shared request-log store when one is
142+
# configured; otherwise logs are emitted only to stdout. The store's
143+
# location is process configuration, set with the REQUEST_LOG_STORE_BACKEND
144+
# and REQUEST_LOG_STORE_DSN environment variables — not here.
143145
persist: false
144-
# backend: sqlite | postgres
145-
backend: sqlite
146-
# SQLite file path or Postgres DSN (required for postgres backend).
147-
dsn: ferrogw-requests.db
148146

149147
# Advanced guardrails (pii-redact, secret-scan, prompt-shield, schema-guard,
150148
# regex-guard) are available in FerroCloud. See https://docs.ferrolabs.ai/guardrails

gateway.go

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/ferro-labs/ai-gateway/internal/latency"
2424
"github.com/ferro-labs/ai-gateway/internal/mcp"
2525
"github.com/ferro-labs/ai-gateway/internal/metrics"
26+
"github.com/ferro-labs/ai-gateway/internal/requestlog"
2627
"github.com/ferro-labs/ai-gateway/internal/strategies"
2728
"github.com/ferro-labs/ai-gateway/models"
2829
"github.com/ferro-labs/ai-gateway/observability"
@@ -41,6 +42,7 @@ type Gateway struct {
4142
strategy strategies.Strategy
4243
streamingContent []streamingContentCondition
4344
plugins *plugin.Manager
45+
requestLogWriter requestlog.Writer
4446
closeOnce sync.Once
4547
hooks []EventHookFunc
4648
hookSnapshot atomic.Value
@@ -165,6 +167,21 @@ func (g *Gateway) SetObservability(p observability.Provider) {
165167
}
166168
}
167169

170+
// SetRequestLogWriter installs the shared request-log store that logging
171+
// plugins write through, instead of each opening its own. Pass the store built
172+
// from REQUEST_LOG_STORE_BACKEND / REQUEST_LOG_STORE_DSN, or nil to leave
173+
// logging plugins without a persistence target.
174+
//
175+
// Safe to call only at startup, before serving traffic and before LoadPlugins,
176+
// since the writer is injected into plugins as they are built. The store is
177+
// owned by the caller (closed on shutdown); the gateway only hands it to
178+
// plugins.
179+
func (g *Gateway) SetRequestLogWriter(w requestlog.Writer) {
180+
g.mu.Lock()
181+
defer g.mu.Unlock()
182+
g.requestLogWriter = w
183+
}
184+
168185
// Observability returns the current observability.Provider. Always
169186
// non-nil; defaults to NoOp.
170187
func (g *Gateway) Observability() observability.Provider {
@@ -282,7 +299,7 @@ func (g *Gateway) ReloadConfig(ctx context.Context, cfg Config) error {
282299
if err != nil {
283300
return fmt.Errorf("invalid config: %w", err)
284301
}
285-
plugins, err := buildPluginManager(cfg.Plugins)
302+
plugins, err := g.buildPluginManager(cfg.Plugins)
286303
if err != nil {
287304
return err
288305
}
@@ -327,7 +344,7 @@ func (g *Gateway) LoadPlugins() error {
327344
g.mu.RLock()
328345
configs := append([]PluginConfig(nil), g.config.Plugins...)
329346
g.mu.RUnlock()
330-
plugins, err := buildPluginManager(configs)
347+
plugins, err := g.buildPluginManager(configs)
331348
if err != nil {
332349
return err
333350
}
@@ -342,7 +359,11 @@ func (g *Gateway) LoadPlugins() error {
342359
return nil
343360
}
344361

345-
func buildPluginManager(configs []PluginConfig) (*plugin.Manager, error) {
362+
func (g *Gateway) buildPluginManager(configs []PluginConfig) (*plugin.Manager, error) {
363+
g.mu.RLock()
364+
sharedLogWriter := g.requestLogWriter
365+
g.mu.RUnlock()
366+
346367
plugins := plugin.NewManager()
347368
for _, pc := range configs {
348369
if !pc.Enabled {
@@ -354,6 +375,15 @@ func buildPluginManager(configs []PluginConfig) (*plugin.Manager, error) {
354375
return nil, fmt.Errorf("unknown plugin: %s", pc.Name)
355376
}
356377
p := factory()
378+
// Hand the shared request-log store to plugins that record through it,
379+
// before Init so Init can direct persistence at it. Plugins never open
380+
// their own store from config, so no request-supplied value reaches the
381+
// filesystem.
382+
if sharedLogWriter != nil {
383+
if r, ok := p.(requestlog.WriterReceiver); ok {
384+
r.SetRequestLogWriter(sharedLogWriter)
385+
}
386+
}
357387
if err := p.Init(pc.Config); err != nil {
358388
_ = plugins.Close()
359389
_ = p.Close()

gateway_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/ferro-labs/ai-gateway/internal/logging"
2121
"github.com/ferro-labs/ai-gateway/internal/metrics"
2222
cacheplugin "github.com/ferro-labs/ai-gateway/internal/plugins/cache"
23+
"github.com/ferro-labs/ai-gateway/internal/requestlog"
2324
"github.com/ferro-labs/ai-gateway/mcp"
2425
"github.com/ferro-labs/ai-gateway/models"
2526
"github.com/ferro-labs/ai-gateway/plugin"
@@ -2889,6 +2890,90 @@ func init() {
28892890
plugin.RegisterFactory("test-plugin", func() plugin.Plugin {
28902891
return &testPlugin{name: "test-plugin", typ: plugin.TypeGuardrail}
28912892
})
2893+
plugin.RegisterFactory("test-log-receiver", func() plugin.Plugin {
2894+
p := &logReceiverPlugin{}
2895+
lastLogReceiver = p
2896+
return p
2897+
})
2898+
}
2899+
2900+
// lastLogReceiver holds the most recently constructed logReceiverPlugin. The
2901+
// receiver tests below run sequentially and build exactly one, so this is a
2902+
// safe way to reach the instance buildPluginManager created and injected.
2903+
var lastLogReceiver *logReceiverPlugin
2904+
2905+
// logReceiverPlugin records the writer the gateway injects, to prove
2906+
// buildPluginManager hands the shared request-log store to receiver plugins.
2907+
type logReceiverPlugin struct {
2908+
injected requestlog.Writer
2909+
wasInjected bool
2910+
}
2911+
2912+
func (p *logReceiverPlugin) Name() string { return "test-log-receiver" }
2913+
func (p *logReceiverPlugin) Type() plugin.PluginType { return plugin.TypeLogging }
2914+
func (p *logReceiverPlugin) Init(map[string]any) error { return nil }
2915+
func (p *logReceiverPlugin) Execute(context.Context, *plugin.Context) error { return nil }
2916+
func (p *logReceiverPlugin) Close() error { return nil }
2917+
func (p *logReceiverPlugin) SetRequestLogWriter(w requestlog.Writer) {
2918+
p.injected = w
2919+
p.wasInjected = true
2920+
}
2921+
2922+
func loadReceiverPlugin(t *testing.T, gw *Gateway) *logReceiverPlugin {
2923+
t.Helper()
2924+
lastLogReceiver = nil
2925+
if err := gw.LoadPlugins(); err != nil {
2926+
t.Fatalf("LoadPlugins failed: %v", err)
2927+
}
2928+
if lastLogReceiver == nil {
2929+
t.Fatal("receiver plugin was never constructed")
2930+
}
2931+
return lastLogReceiver
2932+
}
2933+
2934+
// The shared request-log store the gateway holds is injected into a receiver
2935+
// plugin as it loads.
2936+
func TestGateway_InjectsRequestLogWriterIntoPlugins(t *testing.T) {
2937+
rec := &recordingLogWriter{}
2938+
gw, _ := New(Config{
2939+
Strategy: StrategyConfig{Mode: ModeSingle},
2940+
Targets: []Target{{VirtualKey: mockProviderName}},
2941+
Plugins: []PluginConfig{
2942+
{Name: "test-log-receiver", Type: "logging", Stage: "after_request", Enabled: true},
2943+
},
2944+
})
2945+
gw.SetRequestLogWriter(rec)
2946+
2947+
p := loadReceiverPlugin(t, gw)
2948+
if p.injected != requestlog.Writer(rec) {
2949+
t.Fatalf("plugin injected writer = %v, want the store set via SetRequestLogWriter", p.injected)
2950+
}
2951+
}
2952+
2953+
// Without a shared store, buildPluginManager does not call the receiver at all,
2954+
// so the plugin decides its own fallback rather than being handed nil.
2955+
func TestGateway_NoRequestLogWriter_PluginNotInjected(t *testing.T) {
2956+
gw, _ := New(Config{
2957+
Strategy: StrategyConfig{Mode: ModeSingle},
2958+
Targets: []Target{{VirtualKey: mockProviderName}},
2959+
Plugins: []PluginConfig{
2960+
{Name: "test-log-receiver", Type: "logging", Stage: "after_request", Enabled: true},
2961+
},
2962+
})
2963+
2964+
p := loadReceiverPlugin(t, gw)
2965+
if p.wasInjected {
2966+
t.Fatalf("SetRequestLogWriter was called with %v; want no injection when the gateway has no store", p.injected)
2967+
}
2968+
}
2969+
2970+
type recordingLogWriter struct {
2971+
entries []requestlog.Entry
2972+
}
2973+
2974+
func (w *recordingLogWriter) Write(_ context.Context, e requestlog.Entry) error {
2975+
w.entries = append(w.entries, e)
2976+
return nil
28922977
}
28932978

28942979
func TestGateway_LoadPlugins(t *testing.T) {

internal/admin/admin_keys_handlers.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,8 @@ func (h *Handlers) getKey(w http.ResponseWriter, r *http.Request) {
7878
return
7979
}
8080

81-
masked := *key
82-
masked.Key = maskKey(masked.Key)
83-
8481
w.Header().Set("Content-Type", "application/json")
85-
_ = json.NewEncoder(w).Encode(masked)
82+
_ = json.NewEncoder(w).Encode(key)
8683
}
8784

8885
//nolint:gocyclo // Query parsing + filtering/sorting logic is intentionally centralized for the endpoint.

internal/admin/admin_logs_handlers.go

Lines changed: 20 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -121,100 +121,48 @@ func (h *Handlers) logsStats(w http.ResponseWriter, r *http.Request) {
121121
return
122122
}
123123

124-
baseQuery := requestlog.Query{
125-
Limit: 200,
126-
Offset: 0,
124+
query := requestlog.Query{
127125
Stage: r.URL.Query().Get("stage"),
128126
Model: r.URL.Query().Get("model"),
129127
Provider: r.URL.Query().Get("provider"),
130128
Since: since,
131129
}
132130

133-
result, err := h.Logs.List(r.Context(), baseQuery)
131+
stats, err := h.Logs.Stats(r.Context(), query)
134132
if err != nil {
135133
writeError(w, http.StatusInternalServerError, "failed to compute request log stats", "server_error", "internal_error")
136134
return
137135
}
138136

139-
entries := make([]requestlog.Entry, 0, min(result.Total, logsStatsMaxScannedEntries))
140-
entries = append(entries, result.Data...)
141-
for len(entries) < result.Total && len(entries) < logsStatsMaxScannedEntries {
142-
baseQuery.Offset = len(entries)
143-
next, listErr := h.Logs.List(r.Context(), baseQuery)
144-
if listErr != nil {
145-
writeError(w, http.StatusInternalServerError, "failed to compute request log stats", "server_error", "internal_error")
146-
return
147-
}
148-
if len(next.Data) == 0 {
149-
break
150-
}
151-
remaining := logsStatsMaxScannedEntries - len(entries)
152-
if remaining <= 0 {
153-
break
154-
}
155-
if len(next.Data) > remaining {
156-
next.Data = next.Data[:remaining]
157-
}
158-
entries = append(entries, next.Data...)
159-
}
160-
truncated := len(entries) < result.Total
161-
162-
byStage := map[string]int{}
163-
byProvider := map[string]int{}
164-
byModel := map[string]int{}
165-
errorCount := 0
166-
tokens := 0
167-
for _, entry := range entries {
168-
stage := entry.Stage
169-
if stage == "" {
170-
stage = unknownLabel
171-
}
172-
byStage[stage]++
173-
174-
provider := entry.Provider
175-
if provider == "" {
176-
provider = unknownLabel
177-
}
178-
byProvider[provider]++
179-
180-
model := entry.Model
181-
if model == "" {
182-
model = unknownLabel
183-
}
184-
byModel[model]++
185-
186-
if entry.ErrorMessage != "" || stage == "on_error" {
187-
errorCount++
188-
}
189-
tokens += entry.TotalTokens
190-
}
191-
192-
byProvider = limitCounts(byProvider, limit)
193-
byModel = limitCounts(byModel, limit)
194-
195137
w.Header().Set("Content-Type", "application/json")
196138
_ = json.NewEncoder(w).Encode(map[string]any{
197139
"summary": map[string]any{
198-
"total_entries": len(entries),
199-
"error_entries": errorCount,
200-
"total_tokens": tokens,
201-
"truncated": truncated,
202-
"available_entries": result.Total,
203-
"scan_limit": logsStatsMaxScannedEntries,
140+
"total_entries": stats.TotalEntries,
141+
"error_entries": stats.ErrorEntries,
142+
"total_tokens": stats.TotalTokens,
204143
},
205-
"by_stage": byStage,
206-
"by_provider": byProvider,
207-
"by_model": byModel,
144+
"by_stage": nonNilCounts(stats.ByStage),
145+
"by_provider": nonNilCounts(limitCounts(stats.ByProvider, limit)),
146+
"by_model": nonNilCounts(limitCounts(stats.ByModel, limit)),
208147
"filters": map[string]any{
209148
"limit": limit,
210-
"stage": baseQuery.Stage,
211-
"model": baseQuery.Model,
212-
"provider": baseQuery.Provider,
149+
"stage": query.Stage,
150+
"model": query.Model,
151+
"provider": query.Provider,
213152
"since": r.URL.Query().Get("since"),
214153
},
215154
})
216155
}
217156

157+
// nonNilCounts keeps an absent dimension encoding as {} rather than null, which
158+
// clients index into without checking.
159+
func nonNilCounts(input map[string]int) map[string]int {
160+
if input == nil {
161+
return map[string]int{}
162+
}
163+
return input
164+
}
165+
218166
func limitCounts(input map[string]int, limit int) map[string]int {
219167
if limit <= 0 || len(input) <= limit {
220168
return input

0 commit comments

Comments
 (0)