Skip to content

Commit fea5f12

Browse files
committed
feat: redesign request log persistence
1 parent b7af5e9 commit fea5f12

14 files changed

Lines changed: 804 additions & 100 deletions

File tree

src/main/requestLogs/manager.ts

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
2+
import { join } from 'node:path'
3+
4+
import type { RequestLogEntry } from '../store/types.ts'
5+
import type {
6+
RequestLogConfig,
7+
RequestLogFilter,
8+
RequestLogStats,
9+
RequestLogTrendPoint,
10+
} from './types.ts'
11+
import { normalizeRequestLogConfig } from './types.ts'
12+
import {
13+
sanitizeRequestLogEntry,
14+
sanitizeRequestLogUpdates,
15+
trimRequestLogsToMaxEntries,
16+
} from './sanitizer.ts'
17+
18+
interface RequestLogManagerOptions {
19+
storageDir: string
20+
config?: Partial<RequestLogConfig>
21+
}
22+
23+
export class RequestLogManager {
24+
private readonly logFile: string
25+
private readonly storageDir: string
26+
private requestLogs: RequestLogEntry[] = []
27+
private config: RequestLogConfig
28+
private initialized = false
29+
30+
constructor(options: RequestLogManagerOptions) {
31+
this.storageDir = options.storageDir
32+
this.logFile = join(options.storageDir, 'request-logs.ndjson')
33+
this.config = normalizeRequestLogConfig(options.config)
34+
}
35+
36+
async initialize(): Promise<void> {
37+
if (this.initialized) return
38+
39+
mkdirSync(this.storageDir, { recursive: true })
40+
this.requestLogs = this.loadRequestLogs()
41+
this.initialized = true
42+
}
43+
44+
setConfig(config: Partial<RequestLogConfig>): void {
45+
this.config = normalizeRequestLogConfig(config)
46+
this.requestLogs = trimRequestLogsToMaxEntries(this.requestLogs, this.config)
47+
this.persist()
48+
}
49+
50+
async migrateLegacyLogs(legacyLogs: RequestLogEntry[]): Promise<boolean> {
51+
this.ensureInitialized()
52+
53+
if (legacyLogs.length === 0 || this.requestLogs.length > 0) {
54+
return false
55+
}
56+
57+
this.requestLogs = trimRequestLogsToMaxEntries(
58+
legacyLogs
59+
.sort((a, b) => a.timestamp - b.timestamp)
60+
.map((entry) => ({
61+
...sanitizeRequestLogEntry(stripId(entry), this.config),
62+
id: entry.id,
63+
})),
64+
this.config,
65+
)
66+
this.persist()
67+
68+
return true
69+
}
70+
71+
addRequestLog(entry: Omit<RequestLogEntry, 'id'>): RequestLogEntry {
72+
this.ensureInitialized()
73+
74+
const logEntry: RequestLogEntry = {
75+
...sanitizeRequestLogEntry(entry, this.config),
76+
id: generateId(),
77+
}
78+
79+
if (!this.config.enabled) {
80+
return logEntry
81+
}
82+
83+
this.requestLogs.push(logEntry)
84+
this.requestLogs = trimRequestLogsToMaxEntries(this.requestLogs, this.config)
85+
this.persist()
86+
87+
return logEntry
88+
}
89+
90+
updateRequestLog(id: string, updates: Partial<RequestLogEntry>): boolean {
91+
this.ensureInitialized()
92+
if (!this.config.enabled || this.config.maxEntries <= 0) {
93+
return false
94+
}
95+
96+
const index = this.requestLogs.findIndex((entry) => entry.id === id)
97+
if (index === -1) {
98+
return false
99+
}
100+
101+
this.requestLogs[index] = {
102+
...this.requestLogs[index],
103+
...sanitizeRequestLogUpdates(updates, this.config),
104+
}
105+
this.persist()
106+
return true
107+
}
108+
109+
getRequestLogs(limit?: number, filter?: RequestLogFilter): RequestLogEntry[] {
110+
this.ensureInitialized()
111+
let result = [...this.requestLogs]
112+
113+
if (filter?.status) {
114+
result = result.filter((entry) => entry.status === filter.status)
115+
}
116+
117+
if (filter?.providerId) {
118+
result = result.filter((entry) => entry.providerId === filter.providerId)
119+
}
120+
121+
result.sort((a, b) => b.timestamp - a.timestamp)
122+
123+
if (limit && result.length > limit) {
124+
return result.slice(0, limit)
125+
}
126+
127+
return result
128+
}
129+
130+
getRequestLogById(id: string): RequestLogEntry | undefined {
131+
this.ensureInitialized()
132+
return this.requestLogs.find((entry) => entry.id === id)
133+
}
134+
135+
clearRequestLogs(): void {
136+
this.ensureInitialized()
137+
this.requestLogs = []
138+
this.persist()
139+
}
140+
141+
getRequestLogStats(): RequestLogStats {
142+
this.ensureInitialized()
143+
const today = new Date().toISOString().split('T')[0]
144+
const todayStart = new Date(today).getTime()
145+
const todayEnd = todayStart + 24 * 60 * 60 * 1000
146+
const todayLogs = this.requestLogs.filter((entry) => entry.timestamp >= todayStart && entry.timestamp < todayEnd)
147+
148+
return {
149+
total: this.requestLogs.length,
150+
success: this.requestLogs.filter((entry) => entry.status === 'success').length,
151+
error: this.requestLogs.filter((entry) => entry.status === 'error').length,
152+
todayTotal: todayLogs.length,
153+
todaySuccess: todayLogs.filter((entry) => entry.status === 'success').length,
154+
todayError: todayLogs.filter((entry) => entry.status === 'error').length,
155+
}
156+
}
157+
158+
getRequestLogTrend(days: number = 7): RequestLogTrendPoint[] {
159+
this.ensureInitialized()
160+
const dayMs = 24 * 60 * 60 * 1000
161+
const today = new Date().toISOString().split('T')[0]
162+
const todayStart = new Date(today).getTime()
163+
const trends: RequestLogTrendPoint[] = []
164+
165+
for (let i = days - 1; i >= 0; i--) {
166+
const dayStart = todayStart - i * dayMs
167+
const dayEnd = dayStart + dayMs
168+
const date = new Date(dayStart).toISOString().split('T')[0]
169+
const dayLogs = this.requestLogs.filter((entry) => entry.timestamp >= dayStart && entry.timestamp < dayEnd)
170+
const successLogs = dayLogs.filter((entry) => entry.status === 'success')
171+
const errorLogs = dayLogs.filter((entry) => entry.status === 'error')
172+
const totalLatency = successLogs.reduce((sum, entry) => sum + entry.latency, 0)
173+
174+
trends.push({
175+
date,
176+
total: dayLogs.length,
177+
success: successLogs.length,
178+
error: errorLogs.length,
179+
avgLatency: successLogs.length > 0 ? Math.round(totalLatency / successLogs.length) : 0,
180+
})
181+
}
182+
183+
return trends
184+
}
185+
186+
exportRequestLogs(): RequestLogEntry[] {
187+
this.ensureInitialized()
188+
return [...this.requestLogs]
189+
}
190+
191+
private ensureInitialized(): void {
192+
if (!this.initialized) {
193+
throw new Error('RequestLogManager is not initialized')
194+
}
195+
}
196+
197+
private loadRequestLogs(): RequestLogEntry[] {
198+
if (!existsSync(this.logFile)) {
199+
return []
200+
}
201+
202+
const content = readFileSync(this.logFile, 'utf-8').trim()
203+
if (!content) {
204+
return []
205+
}
206+
207+
return content
208+
.split('\n')
209+
.filter(Boolean)
210+
.map((line) => {
211+
try {
212+
return JSON.parse(line) as RequestLogEntry
213+
} catch {
214+
return null
215+
}
216+
})
217+
.filter((entry): entry is RequestLogEntry => entry !== null)
218+
}
219+
220+
private persist(): void {
221+
const content = this.requestLogs.map((entry) => JSON.stringify(entry)).join('\n')
222+
writeFileSync(this.logFile, content, 'utf-8')
223+
}
224+
}
225+
226+
function generateId(): string {
227+
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
228+
}
229+
230+
function stripId(entry: RequestLogEntry): Omit<RequestLogEntry, 'id'> {
231+
const { id: _id, ...rest } = entry
232+
return rest
233+
}

src/main/requestLogs/sanitizer.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { RequestLogEntry } from '../store/types.ts'
2+
import type { RequestLogConfig } from './types.ts'
3+
4+
export function sanitizeRequestLogEntry(
5+
entry: Omit<RequestLogEntry, 'id'>,
6+
config: RequestLogConfig,
7+
): Omit<RequestLogEntry, 'id'> {
8+
const sanitized: Omit<RequestLogEntry, 'id'> = {
9+
...entry,
10+
userInput: truncateText(entry.userInput, 500),
11+
errorStack: undefined,
12+
}
13+
14+
if (!config.includeBodies) {
15+
sanitized.requestBody = undefined
16+
sanitized.responseBody = undefined
17+
sanitized.responsePreview = truncateText(entry.responsePreview, 1000)
18+
sanitized.errorMessage = truncateText(entry.errorMessage, 1000)
19+
return sanitized
20+
}
21+
22+
sanitized.requestBody = sanitizeRequestLogBody(entry.requestBody, config)
23+
sanitized.responseBody = sanitizeRequestLogBody(entry.responseBody, config)
24+
sanitized.responsePreview = truncateText(entry.responsePreview, 1000)
25+
sanitized.errorMessage = truncateText(entry.errorMessage, 1000)
26+
27+
return sanitized
28+
}
29+
30+
export function sanitizeRequestLogUpdates(
31+
updates: Partial<RequestLogEntry>,
32+
config: RequestLogConfig,
33+
): Partial<RequestLogEntry> {
34+
const sanitized: Partial<RequestLogEntry> = {
35+
...updates,
36+
userInput: truncateText(updates.userInput, 500),
37+
errorStack: undefined,
38+
}
39+
40+
if (!config.includeBodies) {
41+
sanitized.requestBody = undefined
42+
sanitized.responseBody = undefined
43+
sanitized.responsePreview = truncateText(updates.responsePreview, 1000)
44+
sanitized.errorMessage = truncateText(updates.errorMessage, 1000)
45+
return sanitized
46+
}
47+
48+
sanitized.requestBody = sanitizeRequestLogBody(updates.requestBody, config)
49+
sanitized.responseBody = sanitizeRequestLogBody(updates.responseBody, config)
50+
sanitized.responsePreview = truncateText(updates.responsePreview, 1000)
51+
sanitized.errorMessage = truncateText(updates.errorMessage, 1000)
52+
53+
return sanitized
54+
}
55+
56+
export function trimRequestLogsToMaxEntries(
57+
entries: RequestLogEntry[],
58+
config: RequestLogConfig,
59+
): RequestLogEntry[] {
60+
const maxEntries = Math.max(0, config.maxEntries)
61+
if (maxEntries === 0) {
62+
return []
63+
}
64+
65+
if (entries.length <= maxEntries) {
66+
return entries
67+
}
68+
69+
return entries.slice(entries.length - maxEntries)
70+
}
71+
72+
function sanitizeRequestLogBody(value: string | undefined, config: RequestLogConfig): string | undefined {
73+
if (!value) return value
74+
75+
const redacted = config.redactSensitiveData ? redactSensitiveText(value) : value
76+
return truncateText(redacted, config.maxBodyChars)
77+
}
78+
79+
function truncateText(value: string | undefined, maxChars: number): string | undefined {
80+
if (!value) return value
81+
if (maxChars <= 0) return undefined
82+
if (value.length <= maxChars) return value
83+
return `${value.slice(0, maxChars)}...[truncated ${value.length - maxChars} chars]`
84+
}
85+
86+
function redactSensitiveText(value: string): string {
87+
return value.replace(
88+
/(\"?(?:authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|cookie|set-cookie|password|token)\"?\s*[:=]\s*)\"?[^\",}\]\s]+\"?/gi,
89+
'$1"[REDACTED]"',
90+
)
91+
}

src/main/requestLogs/types.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { RequestLogConfig } from '../store/types.ts'
2+
3+
export type { RequestLogConfig } from '../store/types.ts'
4+
5+
export interface RequestLogFilter {
6+
status?: 'success' | 'error'
7+
providerId?: string
8+
}
9+
10+
export interface RequestLogStats {
11+
total: number
12+
success: number
13+
error: number
14+
todayTotal: number
15+
todaySuccess: number
16+
todayError: number
17+
}
18+
19+
export interface RequestLogTrendPoint {
20+
date: string
21+
total: number
22+
success: number
23+
error: number
24+
avgLatency: number
25+
}
26+
27+
export function normalizeRequestLogConfig(config?: Partial<RequestLogConfig>): RequestLogConfig {
28+
return {
29+
enabled: true,
30+
maxEntries: 200,
31+
includeBodies: false,
32+
maxBodyChars: 8000,
33+
redactSensitiveData: true,
34+
...config,
35+
}
36+
}

0 commit comments

Comments
 (0)