Skip to content

Commit 85be3d2

Browse files
whoabuddyclaudeCopilot
authored
feat(logs): add admin aggregated logs endpoint (#12)
* feat(logs): add admin aggregated GET /logs endpoint When admin calls GET /logs without X-App-ID, iterates all registered apps from KV, queries each app's DO in parallel, and returns combined log entries with app_id field on each entry. Preserves existing single-app behavior when X-App-ID header is provided (for both API key and admin auth paths). Per-app limit is calculated as max(10, ceil(globalLimit / numApps)) to avoid overloading any single DO. Results are merged, sorted by timestamp DESC, and truncated to the global limit. Also exports AggregatedLogEntry type (LogEntry + app_id) from types.ts for consumers that need to type-check aggregated responses. Co-Authored-By: Claude <noreply@anthropic.com> * test(logs): add integration tests for admin aggregated logs Tests cover: - GET /logs with admin key and no X-App-ID returns combined entries from all registered apps, each with an app_id field - Level filter (e.g., ?level=ERROR) is forwarded to each app's DO - Global limit (e.g., ?limit=2) caps the total results returned - GET /logs with admin key AND X-App-ID falls through to single-app path, returning raw DO response without app_id field Adds beforeAll setup that creates two apps (agg-app-1, agg-app-2) and writes distinct log levels to each so assertions can verify cross-app aggregation. Co-Authored-By: Claude <noreply@anthropic.com> * test(aggregated-logs): add filter and auth tests for admin all-logs endpoint Extend the "Admin aggregated logs" integration test suite with 7 new tests that cover scenarios not already tested in Phase 1: Auth edge cases: - No auth (missing both X-Admin-Key and X-App-ID) returns 400 - Invalid admin key returns 401 Aggregated filter scenarios (new nested describe with isolated app fixtures): - since=far-future timestamp returns empty array (filter applied per-app) - until=far-past timestamp returns empty array - search filter returns matching entries across multiple apps - context.* filter returns entries with matching JSON context field - request_id filter returns exactly the one matching entry with correct app_id Each test verifies that app_id field is present on aggregated results and that filters are correctly propagated to per-app DO queries. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(logs): simplify aggregated logs limit parsing and remove unused test variables Replace verbose has/get/parseInt pattern with Number() + nullish fallback for the globalLimit parameter. Remove dead-code queryString conditional since perAppParams always contains at least the limit key. Remove unused filterApiKey1/filterApiKey2 variables from test setup -- the filter tests authenticate exclusively via admin key. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * use string comparison for sort Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 1dc3f1d commit 85be3d2

3 files changed

Lines changed: 389 additions & 11 deletions

File tree

src/index.ts

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Hono } from 'hono'
22
import { cors } from 'hono/cors'
33
import { Ok, Err, ErrorCode } from './result'
4-
import type { Env, LogInput, LogBatchInput } from './types'
4+
import type { Env, LogInput, LogBatchInput, LogEntry, AggregatedLogEntry } from './types'
55
import * as registry from './services/registry'
66
import { requireApiKey, requireAdminKey, requireApiKeyOrAdmin } from './middleware/auth'
77
import { dashboard } from './dashboard/index'
@@ -14,7 +14,7 @@ export { AppLogsDO } from './durable-objects/app-logs-do'
1414
export { LogsRPC } from './rpc'
1515

1616
// Re-export types for consumers
17-
export type { LogInput, LogEntry, LogLevel, QueryFilters, DailyStats } from './types'
17+
export type { LogInput, LogEntry, AggregatedLogEntry, LogLevel, QueryFilters, DailyStats } from './types'
1818

1919
type Variables = {
2020
appId: string
@@ -37,7 +37,7 @@ app.get('/', (c) => {
3737
endpoints: {
3838
'GET /dashboard': 'Web UI for browsing logs (requires admin key)',
3939
'POST /logs': 'Write log entries (requires API key)',
40-
'GET /logs': 'Query log entries (requires API key)',
40+
'GET /logs': 'Query log entries (requires API key or admin)',
4141
'GET /health/:app_id': 'Get health check history (public)',
4242
'GET /stats/:app_id': 'Get daily stats (requires API key or admin)',
4343
'POST /apps/:app_id/prune': 'Delete old logs (requires API key)',
@@ -98,16 +98,72 @@ app.post('/logs', requireApiKey, async (c) => {
9898
}
9999
})
100100

101-
// GET /logs - Query logs (requires API key)
102-
app.get('/logs', requireApiKey, async (c) => {
103-
const appId = c.get('appId')
104-
const stub = getAppDO(c.env, appId)
101+
// GET /logs - Query logs (requires API key or admin)
102+
// When admin auth is used without X-App-ID, returns aggregated logs from all registered apps.
103+
// When X-App-ID is provided (admin or API key auth), returns single-app logs as before.
104+
app.get('/logs', requireApiKeyOrAdmin, async (c) => {
105+
// API key auth: appId is set by middleware after validation
106+
// Admin auth with X-App-ID header: use that app directly
107+
const appId = c.get('appId') || c.req.header('X-App-ID')
108+
109+
// Single-app query (API key auth or admin with explicit X-App-ID)
110+
if (appId) {
111+
const stub = getAppDO(c.env, appId)
112+
const url = new URL(c.req.url)
113+
114+
const res = await stub.fetch(new Request(`http://do/logs${url.search}`, {
115+
method: 'GET',
116+
}))
117+
return c.json(await res.json())
118+
}
119+
120+
// Admin aggregated query: no appId means admin auth without X-App-ID
121+
if (!c.env.LOGS_KV) {
122+
return c.json(Err({ code: ErrorCode.INTERNAL_ERROR, message: 'KV namespace not configured' }), 500)
123+
}
124+
125+
const appsResult = await registry.listApps(c.env.LOGS_KV)
126+
if (!appsResult.ok) {
127+
return c.json(appsResult, 500)
128+
}
129+
130+
const appIds = appsResult.data
131+
if (appIds.length === 0) {
132+
return c.json(Ok([] as AggregatedLogEntry[]))
133+
}
134+
105135
const url = new URL(c.req.url)
136+
const globalLimit = Number(url.searchParams.get('limit')) || 100
137+
const perAppLimit = Math.max(10, Math.ceil(globalLimit / appIds.length))
138+
139+
// Build per-app query string with overridden limit; offset is omitted (not meaningful in aggregated mode)
140+
const perAppParams = new URLSearchParams(url.search)
141+
perAppParams.set('limit', String(perAppLimit))
142+
perAppParams.delete('offset')
143+
const queryString = `?${perAppParams.toString()}`
144+
145+
const results = await Promise.all(
146+
appIds.map(async (id) => {
147+
try {
148+
const stub = getAppDO(c.env, id)
149+
const res = await stub.fetch(new Request(`http://do/logs${queryString}`, { method: 'GET' }))
150+
const json = await res.json() as { ok: boolean; data: LogEntry[] }
151+
if (json.ok && Array.isArray(json.data)) {
152+
return json.data.map((entry): AggregatedLogEntry => ({ ...entry, app_id: id }))
153+
}
154+
return [] as AggregatedLogEntry[]
155+
} catch {
156+
// Skip apps that fail to respond rather than failing the entire request
157+
return [] as AggregatedLogEntry[]
158+
}
159+
})
160+
)
106161

107-
const res = await stub.fetch(new Request(`http://do/logs${url.search}`, {
108-
method: 'GET',
109-
}))
110-
return c.json(await res.json())
162+
const merged = results.flat()
163+
merged.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1))
164+
const truncated = merged.slice(0, globalLimit)
165+
166+
return c.json(Ok(truncated))
111167
})
112168

113169
// GET /health/:app_id - Get health check history

src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ export interface DailyStats {
109109
error: number
110110
}
111111

112+
/**
113+
* A log entry with an app_id field, returned by admin aggregated queries
114+
*/
115+
export interface AggregatedLogEntry extends LogEntry {
116+
app_id: string
117+
}
118+
112119
/**
113120
* Prune request
114121
*/

0 commit comments

Comments
 (0)