Skip to content

Commit 6d75a58

Browse files
committed
feat: consola file logger for handler.ts
1 parent 5c6e4c6 commit 6d75a58

4 files changed

Lines changed: 214 additions & 29 deletions

File tree

src/lib/logger.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import consola, { type ConsolaInstance } from "consola"
2+
import fs from "node:fs"
3+
import path from "node:path"
4+
import util from "node:util"
5+
6+
import { PATHS } from "./paths"
7+
8+
const LOG_RETENTION_DAYS = 7
9+
const LOG_RETENTION_MS = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000
10+
const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000
11+
const LOG_DIR = path.join(PATHS.APP_DIR, "logs")
12+
const FLUSH_INTERVAL_MS = 1000
13+
const MAX_BUFFER_SIZE = 100
14+
15+
const logStreams = new Map<string, fs.WriteStream>()
16+
const logBuffers = new Map<string, Array<string>>()
17+
18+
const ensureLogDirectory = () => {
19+
if (!fs.existsSync(LOG_DIR)) {
20+
fs.mkdirSync(LOG_DIR, { recursive: true })
21+
}
22+
}
23+
24+
const cleanupOldLogs = () => {
25+
if (!fs.existsSync(LOG_DIR)) {
26+
return
27+
}
28+
29+
const now = Date.now()
30+
31+
for (const entry of fs.readdirSync(LOG_DIR)) {
32+
const filePath = path.join(LOG_DIR, entry)
33+
34+
let stats: fs.Stats
35+
try {
36+
stats = fs.statSync(filePath)
37+
} catch {
38+
continue
39+
}
40+
41+
if (!stats.isFile()) {
42+
continue
43+
}
44+
45+
if (now - stats.mtimeMs > LOG_RETENTION_MS) {
46+
try {
47+
fs.rmSync(filePath)
48+
} catch {
49+
continue
50+
}
51+
}
52+
}
53+
}
54+
55+
const formatArgs = (args: Array<unknown>) =>
56+
args
57+
.map((arg) =>
58+
typeof arg === "string" ? arg : (
59+
util.inspect(arg, { depth: null, colors: false })
60+
),
61+
)
62+
.join(" ")
63+
64+
const sanitizeName = (name: string) => {
65+
const normalized = name
66+
.toLowerCase()
67+
.replaceAll(/[^a-z0-9]+/g, "-")
68+
.replaceAll(/^-+|-+$/g, "")
69+
70+
return normalized === "" ? "handler" : normalized
71+
}
72+
73+
const getLogStream = (filePath: string): fs.WriteStream => {
74+
let stream = logStreams.get(filePath)
75+
if (!stream || stream.destroyed) {
76+
stream = fs.createWriteStream(filePath, { flags: "a" })
77+
logStreams.set(filePath, stream)
78+
79+
stream.on("error", (error: unknown) => {
80+
console.warn("Log stream error", error)
81+
logStreams.delete(filePath)
82+
})
83+
}
84+
return stream
85+
}
86+
87+
const flushBuffer = (filePath: string) => {
88+
const buffer = logBuffers.get(filePath)
89+
if (!buffer || buffer.length === 0) {
90+
return
91+
}
92+
93+
const stream = getLogStream(filePath)
94+
const content = buffer.join("\n") + "\n"
95+
stream.write(content, (error) => {
96+
if (error) {
97+
console.warn("Failed to write handler log", error)
98+
}
99+
})
100+
101+
logBuffers.set(filePath, [])
102+
}
103+
104+
const flushAllBuffers = () => {
105+
for (const filePath of logBuffers.keys()) {
106+
flushBuffer(filePath)
107+
}
108+
}
109+
110+
const appendLine = (filePath: string, line: string) => {
111+
let buffer = logBuffers.get(filePath)
112+
if (!buffer) {
113+
buffer = []
114+
logBuffers.set(filePath, buffer)
115+
}
116+
117+
buffer.push(line)
118+
119+
if (buffer.length >= MAX_BUFFER_SIZE) {
120+
flushBuffer(filePath)
121+
}
122+
}
123+
124+
setInterval(flushAllBuffers, FLUSH_INTERVAL_MS)
125+
126+
const cleanup = () => {
127+
flushAllBuffers()
128+
for (const stream of logStreams.values()) {
129+
stream.end()
130+
}
131+
logStreams.clear()
132+
logBuffers.clear()
133+
}
134+
135+
process.on("exit", cleanup)
136+
process.on("SIGINT", () => {
137+
cleanup()
138+
process.exit(0)
139+
})
140+
process.on("SIGTERM", () => {
141+
cleanup()
142+
process.exit(0)
143+
})
144+
145+
let lastCleanup = 0
146+
147+
export const createHandlerLogger = (name: string): ConsolaInstance => {
148+
ensureLogDirectory()
149+
150+
const sanitizedName = sanitizeName(name)
151+
const instance = consola.withTag(name)
152+
153+
instance.level = 5
154+
instance.setReporters([])
155+
156+
instance.addReporter({
157+
log(logObj) {
158+
ensureLogDirectory()
159+
160+
if (Date.now() - lastCleanup > CLEANUP_INTERVAL_MS) {
161+
cleanupOldLogs()
162+
lastCleanup = Date.now()
163+
}
164+
165+
const date = logObj.date
166+
const dateKey = date.toLocaleDateString("sv-SE")
167+
const timestamp = date.toLocaleString("sv-SE", { hour12: false })
168+
const filePath = path.join(LOG_DIR, `${sanitizedName}-${dateKey}.log`)
169+
const message = formatArgs(logObj.args as Array<unknown>)
170+
const line = `[${timestamp}] [${logObj.type}] [${logObj.tag || name}]${
171+
message ? ` ${message}` : ""
172+
}`
173+
174+
appendLine(filePath, line)
175+
},
176+
})
177+
178+
return instance
179+
}

src/routes/chat-completions/handler.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import type { Context } from "hono"
22

3-
import consola from "consola"
43
import { streamSSE, type SSEMessage } from "hono/streaming"
54

65
import { awaitApproval } from "~/lib/approval"
6+
import { createHandlerLogger } from "~/lib/logger"
77
import { checkRateLimit } from "~/lib/rate-limit"
88
import { state } from "~/lib/state"
99
import { getTokenCount } from "~/lib/tokenizer"
@@ -14,11 +14,13 @@ import {
1414
type ChatCompletionsPayload,
1515
} from "~/services/copilot/create-chat-completions"
1616

17+
const logger = createHandlerLogger("chat-completions-handler")
18+
1719
export async function handleCompletion(c: Context) {
1820
await checkRateLimit(state)
1921

2022
let payload = await c.req.json<ChatCompletionsPayload>()
21-
consola.debug("Request payload:", JSON.stringify(payload).slice(-400))
23+
logger.debug("Request payload:", JSON.stringify(payload).slice(-400))
2224

2325
// Find the selected model
2426
const selectedModel = state.models?.data.find(
@@ -29,12 +31,12 @@ export async function handleCompletion(c: Context) {
2931
try {
3032
if (selectedModel) {
3133
const tokenCount = await getTokenCount(payload, selectedModel)
32-
consola.info("Current token count:", tokenCount)
34+
logger.info("Current token count:", tokenCount)
3335
} else {
34-
consola.warn("No model selected, skipping token count calculation")
36+
logger.warn("No model selected, skipping token count calculation")
3537
}
3638
} catch (error) {
37-
consola.warn("Failed to calculate token count:", error)
39+
logger.warn("Failed to calculate token count:", error)
3840
}
3941

4042
if (state.manualApprove) await awaitApproval()
@@ -44,20 +46,20 @@ export async function handleCompletion(c: Context) {
4446
...payload,
4547
max_tokens: selectedModel?.capabilities.limits.max_output_tokens,
4648
}
47-
consola.debug("Set max_tokens to:", JSON.stringify(payload.max_tokens))
49+
logger.debug("Set max_tokens to:", JSON.stringify(payload.max_tokens))
4850
}
4951

5052
const response = await createChatCompletions(payload)
5153

5254
if (isNonStreaming(response)) {
53-
consola.debug("Non-streaming response:", JSON.stringify(response))
55+
logger.debug("Non-streaming response:", JSON.stringify(response))
5456
return c.json(response)
5557
}
5658

57-
consola.debug("Streaming response")
59+
logger.debug("Streaming response")
5860
return streamSSE(c, async (stream) => {
5961
for await (const chunk of response) {
60-
consola.debug("Streaming chunk:", JSON.stringify(chunk))
62+
logger.debug("Streaming chunk:", JSON.stringify(chunk))
6163
await stream.writeSSE(chunk as SSEMessage)
6264
}
6365
})

src/routes/messages/handler.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { Context } from "hono"
22

3-
import consola from "consola"
43
import { streamSSE } from "hono/streaming"
54

65
import { awaitApproval } from "~/lib/approval"
76
import { getSmallModel } from "~/lib/config"
7+
import { createHandlerLogger } from "~/lib/logger"
88
import { checkRateLimit } from "~/lib/rate-limit"
99
import { state } from "~/lib/state"
1010
import {
@@ -37,11 +37,13 @@ import {
3737
} from "./non-stream-translation"
3838
import { translateChunkToAnthropicEvents } from "./stream-translation"
3939

40+
const logger = createHandlerLogger("messages-handler")
41+
4042
export async function handleCompletion(c: Context) {
4143
await checkRateLimit(state)
4244

4345
const anthropicPayload = await c.req.json<AnthropicMessagesPayload>()
44-
consola.debug("Anthropic request payload:", JSON.stringify(anthropicPayload))
46+
logger.debug("Anthropic request payload:", JSON.stringify(anthropicPayload))
4547

4648
// fix claude code 2.0.28 warmup request consume premium request, forcing small model if no tools are used
4749
if (!anthropicPayload.tools || anthropicPayload.tools.length === 0) {
@@ -68,27 +70,27 @@ const handleWithChatCompletions = async (
6870
anthropicPayload: AnthropicMessagesPayload,
6971
) => {
7072
const openAIPayload = translateToOpenAI(anthropicPayload)
71-
consola.debug(
73+
logger.debug(
7274
"Translated OpenAI request payload:",
7375
JSON.stringify(openAIPayload),
7476
)
7577

7678
const response = await createChatCompletions(openAIPayload)
7779

7880
if (isNonStreaming(response)) {
79-
consola.debug(
81+
logger.debug(
8082
"Non-streaming response from Copilot:",
8183
JSON.stringify(response).slice(-400),
8284
)
8385
const anthropicResponse = translateToAnthropic(response)
84-
consola.debug(
86+
logger.debug(
8587
"Translated Anthropic response:",
8688
JSON.stringify(anthropicResponse),
8789
)
8890
return c.json(anthropicResponse)
8991
}
9092

91-
consola.debug("Streaming response from Copilot")
93+
logger.debug("Streaming response from Copilot")
9294
return streamSSE(c, async (stream) => {
9395
const streamState: AnthropicStreamState = {
9496
messageStartSent: false,
@@ -98,7 +100,7 @@ const handleWithChatCompletions = async (
98100
}
99101

100102
for await (const rawEvent of response) {
101-
consola.debug("Copilot raw stream event:", JSON.stringify(rawEvent))
103+
logger.debug("Copilot raw stream event:", JSON.stringify(rawEvent))
102104
if (rawEvent.data === "[DONE]") {
103105
break
104106
}
@@ -111,7 +113,7 @@ const handleWithChatCompletions = async (
111113
const events = translateChunkToAnthropicEvents(chunk, streamState)
112114

113115
for (const event of events) {
114-
consola.debug("Translated Anthropic event:", JSON.stringify(event))
116+
logger.debug("Translated Anthropic event:", JSON.stringify(event))
115117
await stream.writeSSE({
116118
event: event.type,
117119
data: JSON.stringify(event),
@@ -127,7 +129,7 @@ const handleWithResponsesApi = async (
127129
) => {
128130
const responsesPayload =
129131
translateAnthropicMessagesToResponsesPayload(anthropicPayload)
130-
consola.debug(
132+
logger.debug(
131133
"Translated Responses payload:",
132134
JSON.stringify(responsesPayload),
133135
)
@@ -139,7 +141,7 @@ const handleWithResponsesApi = async (
139141
})
140142

141143
if (responsesPayload.stream && isAsyncIterable(response)) {
142-
consola.debug("Streaming response from Copilot (Responses API)")
144+
logger.debug("Streaming response from Copilot (Responses API)")
143145
return streamSSE(c, async (stream) => {
144146
const streamState = createResponsesStreamState()
145147

@@ -155,15 +157,15 @@ const handleWithResponsesApi = async (
155157
continue
156158
}
157159

158-
consola.debug("Responses raw stream event:", data)
160+
logger.debug("Responses raw stream event:", data)
159161

160162
const events = translateResponsesStreamEvent(
161163
JSON.parse(data) as ResponseStreamEvent,
162164
streamState,
163165
)
164166
for (const event of events) {
165167
const eventData = JSON.stringify(event)
166-
consola.debug("Translated Anthropic event:", eventData)
168+
logger.debug("Translated Anthropic event:", eventData)
167169
await stream.writeSSE({
168170
event: event.type,
169171
data: eventData,
@@ -172,7 +174,7 @@ const handleWithResponsesApi = async (
172174
}
173175

174176
if (!streamState.messageCompleted) {
175-
consola.warn(
177+
logger.warn(
176178
"Responses stream ended without completion; sending fallback message_stop",
177179
)
178180
const fallback = { type: "message_stop" as const }
@@ -184,14 +186,14 @@ const handleWithResponsesApi = async (
184186
})
185187
}
186188

187-
consola.debug(
189+
logger.debug(
188190
"Non-streaming Responses result:",
189191
JSON.stringify(response).slice(-400),
190192
)
191193
const anthropicResponse = translateResponsesResultToAnthropic(
192194
response as ResponsesResult,
193195
)
194-
consola.debug(
196+
logger.debug(
195197
"Translated Anthropic response:",
196198
JSON.stringify(anthropicResponse),
197199
)

0 commit comments

Comments
 (0)