logs.ts140 lines · main
| 1 | import type Redis from 'ioredis'; |
| 2 | import { Hono } from 'hono'; |
| 3 | import { streamSSE } from 'hono/streaming'; |
| 4 | |
| 5 | import { getRedis } from '../lib/redis.js'; |
| 6 | import { log } from '../lib/logger.js'; |
| 7 | import { requireProjectAuth } from '../middleware/project-auth.js'; |
| 8 | import type { ProjectAppEnv as AppEnv } from '../types/app-env.js'; |
| 9 | |
| 10 | const MAX_CONCURRENT_PER_PROJECT = 10; |
| 11 | const HEARTBEAT_MS = 15_000; |
| 12 | |
| 13 | export const logsRouter = new Hono<AppEnv>(); |
| 14 | |
| 15 | logsRouter.use('/v1/projects/:id/logs/*', requireProjectAuth()); |
| 16 | |
| 17 | /** |
| 18 | * GET /v1/projects/:id/logs/stream?since=<id>&n=<N> |
| 19 | * |
| 20 | * Server-Sent Events stream of function invocation envelopes. Opens a |
| 21 | * dedicated Redis connection per caller so XREAD BLOCK doesn't starve |
| 22 | * the shared command pool. Concurrency is capped per project via a |
| 23 | * counter held on the *shared* Redis to enforce the limit across |
| 24 | * api replicas, not just this process. |
| 25 | */ |
| 26 | logsRouter.get('/v1/projects/:id/logs/stream', async (c) => { |
| 27 | const projectId = c.req.param('id'); |
| 28 | const since = c.req.query('since'); |
| 29 | const replayN = Number(c.req.query('n') ?? 0); |
| 30 | |
| 31 | const sharedRedis = getRedis(); |
| 32 | if (!sharedRedis) { |
| 33 | return c.json({ code: 'not_configured', message: 'redis is not configured' }, 503); |
| 34 | } |
| 35 | |
| 36 | const concurrentKey = `logs:subscribers:${projectId}`; |
| 37 | const current = await sharedRedis.incr(concurrentKey); |
| 38 | if (current === 1) await sharedRedis.pexpire(concurrentKey, 60_000); |
| 39 | if (current > MAX_CONCURRENT_PER_PROJECT) { |
| 40 | await sharedRedis.decr(concurrentKey); |
| 41 | return c.json( |
| 42 | { code: 'too_many_subscribers', message: 'concurrent log streams exceeded' }, |
| 43 | 429, |
| 44 | ); |
| 45 | } |
| 46 | |
| 47 | const streamKey = `logs:${projectId}`; |
| 48 | // Dedicated duplicate connection so blocking XREAD doesn't block any |
| 49 | // other command on the shared ioredis pool. |
| 50 | const blockingRedis = sharedRedis.duplicate(); |
| 51 | |
| 52 | return streamSSE(c, async (stream) => { |
| 53 | let closed = false; |
| 54 | const release = async (): Promise<void> => { |
| 55 | if (closed) return; |
| 56 | closed = true; |
| 57 | try { |
| 58 | await sharedRedis.decr(concurrentKey); |
| 59 | } catch { |
| 60 | // non-fatal |
| 61 | } |
| 62 | blockingRedis.disconnect(); |
| 63 | }; |
| 64 | |
| 65 | stream.onAbort(() => { |
| 66 | void release(); |
| 67 | }); |
| 68 | |
| 69 | try { |
| 70 | // Optional replay-before-follow window. |
| 71 | let cursor = since ?? '0-0'; |
| 72 | if (!since && replayN > 0) { |
| 73 | const tail = await readLastN(sharedRedis, streamKey, replayN); |
| 74 | for (const msg of tail) { |
| 75 | await stream.writeSSE({ data: JSON.stringify(msg.fields), id: msg.id }); |
| 76 | cursor = msg.id; |
| 77 | } |
| 78 | } else if (since) { |
| 79 | // Range replay from `since` inclusive, up to the live head. |
| 80 | const ranged = await sharedRedis.xrange(streamKey, since, '+'); |
| 81 | for (const [id, fields] of ranged) { |
| 82 | const parsed = parseFields(fields); |
| 83 | await stream.writeSSE({ data: JSON.stringify(parsed), id }); |
| 84 | cursor = id; |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // Live follow loop. |
| 89 | while (!closed) { |
| 90 | const reply = (await blockingRedis.xread( |
| 91 | 'BLOCK', |
| 92 | HEARTBEAT_MS, |
| 93 | 'STREAMS', |
| 94 | streamKey, |
| 95 | cursor, |
| 96 | )) as Array<[string, Array<[string, string[]]>]> | null; |
| 97 | |
| 98 | if (!reply) { |
| 99 | // Heartbeat so proxies don't sever the connection. |
| 100 | await stream.writeSSE({ event: 'ping', data: '' }); |
| 101 | continue; |
| 102 | } |
| 103 | for (const [, entries] of reply) { |
| 104 | for (const [id, fields] of entries) { |
| 105 | const parsed = parseFields(fields); |
| 106 | await stream.writeSSE({ data: JSON.stringify(parsed), id }); |
| 107 | cursor = id; |
| 108 | } |
| 109 | } |
| 110 | } |
| 111 | } catch (err) { |
| 112 | log.warn('logs_stream_error', { |
| 113 | projectId, |
| 114 | message: err instanceof Error ? err.message : String(err), |
| 115 | }); |
| 116 | } finally { |
| 117 | await release(); |
| 118 | } |
| 119 | }); |
| 120 | }); |
| 121 | |
| 122 | interface ParsedMessage { |
| 123 | id: string; |
| 124 | fields: Record<string, string>; |
| 125 | } |
| 126 | |
| 127 | async function readLastN(redis: Redis, key: string, n: number): Promise<ParsedMessage[]> { |
| 128 | const rev = await redis.xrevrange(key, '+', '-', 'COUNT', n); |
| 129 | return rev.map(([id, fields]) => ({ id, fields: parseFields(fields) })).reverse(); |
| 130 | } |
| 131 | |
| 132 | function parseFields(flat: string[]): Record<string, string> { |
| 133 | const out: Record<string, string> = {}; |
| 134 | for (let i = 0; i < flat.length; i += 2) { |
| 135 | const k = flat[i]; |
| 136 | const v = flat[i + 1]; |
| 137 | if (typeof k === 'string' && typeof v === 'string') out[k] = v; |
| 138 | } |
| 139 | return out; |
| 140 | } |