Skip to content

Commit 68b4a56

Browse files
committed
fix: error monit
1 parent e62feea commit 68b4a56

File tree

5 files changed

+608
-17
lines changed

5 files changed

+608
-17
lines changed

packages/flowerbase/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const DEFAULT_CONFIG = {
3939
MONIT_CACHE_HOURS: Number(process.env.MONIT_CACHE_HOURS) || 24,
4040
MONIT_MAX_EVENTS: Number(process.env.MONIT_MAX_EVENTS) || 5000,
4141
MONIT_CAPTURE_CONSOLE: parseBoolean(process.env.MONIT_CAPTURE_CONSOLE ?? 'true'),
42+
MONIT_REDACT_ERROR_DETAILS: parseBoolean(process.env.MONIT_REDACT_ERROR_DETAILS ?? 'true'),
4243
CORS_OPTIONS: {
4344
origin: "*",
4445
methods: ["GET", "POST", "PUT", "DELETE"] as ALLOWED_METHODS[]

packages/flowerbase/src/monitoring/plugin.ts

Lines changed: 186 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ type MonitorEvent = {
2525
data?: unknown
2626
}
2727

28+
type FunctionHistoryItem = {
29+
ts: number
30+
name: string
31+
args: unknown[]
32+
runAsSystem: boolean
33+
user?: { id?: string; email?: string }
34+
}
35+
2836
type EventQuery = {
2937
q?: string
3038
type?: string
@@ -91,11 +99,91 @@ const redactString = (value: string) => {
9199
return result
92100
}
93101

102+
const stripSensitiveFields = (value: Record<string, unknown>) => {
103+
const out: Record<string, unknown> = {}
104+
Object.keys(value).forEach((key) => {
105+
if (SENSITIVE_KEYS.some((re) => re.test(key))) return
106+
out[key] = value[key]
107+
})
108+
return out
109+
}
110+
111+
const isErrorLike = (value: unknown): value is Record<string, unknown> => {
112+
if (!value || typeof value !== 'object') return false
113+
if (value instanceof Error) return true
114+
const record = value as Record<string, unknown>
115+
return (
116+
typeof record.message === 'string' ||
117+
typeof record.stack === 'string' ||
118+
typeof record.name === 'string'
119+
)
120+
}
121+
122+
const sanitizeErrorLike = (value: Record<string, unknown>, depth: number): Record<string, unknown> => {
123+
const out: Record<string, unknown> = {}
124+
const names = new Set<string>(Object.getOwnPropertyNames(value))
125+
;['name', 'message', 'stack', 'code', 'statusCode', 'cause'].forEach((key) => names.add(key))
126+
127+
names.forEach((key) => {
128+
if (SENSITIVE_KEYS.some((re) => re.test(key))) {
129+
out[key] = '[redacted]'
130+
return
131+
}
132+
const raw = value[key]
133+
if (raw === value) {
134+
out[key] = '[Circular]'
135+
return
136+
}
137+
if ((key === 'message' || key === 'stack') && typeof raw === 'string') {
138+
out[key] = DEFAULT_CONFIG.MONIT_REDACT_ERROR_DETAILS ? redactString(raw) : raw
139+
return
140+
}
141+
if (typeof raw === 'string') {
142+
out[key] = redactString(raw)
143+
return
144+
}
145+
if (raw !== undefined) {
146+
out[key] = sanitize(raw, depth + 1)
147+
}
148+
})
149+
150+
if (value instanceof Error) {
151+
if (!out.name) out.name = value.name
152+
if (!out.message) {
153+
out.message = DEFAULT_CONFIG.MONIT_REDACT_ERROR_DETAILS ? redactString(value.message) : value.message
154+
}
155+
if (!out.stack && value.stack) {
156+
out.stack = DEFAULT_CONFIG.MONIT_REDACT_ERROR_DETAILS ? redactString(value.stack) : value.stack
157+
}
158+
}
159+
160+
return out
161+
}
162+
163+
const getErrorDetails = (error: unknown) => {
164+
if (isErrorLike(error)) {
165+
const sanitized = sanitizeErrorLike(error as Record<string, unknown>, 0)
166+
const message =
167+
typeof sanitized.message === 'string' && sanitized.message
168+
? sanitized.message
169+
: typeof sanitized.name === 'string' && sanitized.name
170+
? sanitized.name
171+
: safeStringify(error)
172+
const stack = typeof sanitized.stack === 'string' ? sanitized.stack : undefined
173+
return { message, stack }
174+
}
175+
if (typeof error === 'string') return { message: error }
176+
return { message: safeStringify(error) }
177+
}
178+
94179
const sanitize = (value: unknown, depth = 0): unknown => {
95180
if (depth > MAX_DEPTH) return '[max-depth]'
96181
if (value === null || value === undefined) return value
97182
if (value instanceof Date) return value.toISOString()
98183
if (Buffer.isBuffer(value)) return `[buffer ${value.length} bytes]`
184+
if (isErrorLike(value)) {
185+
return sanitizeErrorLike(value as Record<string, unknown>, depth)
186+
}
99187
if (typeof value === 'object') {
100188
const maybeObjectId = value as { _bsontype?: string; toString?: () => string }
101189
if (maybeObjectId?._bsontype === 'ObjectId' && typeof maybeObjectId.toString === 'function') {
@@ -248,6 +336,56 @@ const resolveAssetCandidates = (filename: string, prefix: string) => {
248336
return candidates
249337
}
250338

339+
const resolveUserContext = async (
340+
app: FastifyInstance,
341+
userId?: string,
342+
userPayload?: Record<string, unknown>
343+
) => {
344+
if (userPayload && typeof userPayload === 'object') {
345+
return stripSensitiveFields(userPayload)
346+
}
347+
if (!userId) return undefined
348+
349+
const db = app.mongo.client.db(DB_NAME)
350+
const authCollection = AUTH_CONFIG.authCollection ?? 'auth_users'
351+
const userCollection = AUTH_CONFIG.userCollection
352+
const userIdField = AUTH_CONFIG.user_id_field ?? 'id'
353+
const isObjectId = ObjectId.isValid(userId)
354+
const lowerId = userId.toLowerCase()
355+
const emailLookup = userId.includes('@') ? lowerId : ''
356+
357+
const authSelector = isObjectId
358+
? { _id: new ObjectId(userId) }
359+
: (emailLookup ? { email: emailLookup } : { id: userId })
360+
const authUser = await db.collection(authCollection).findOne(authSelector)
361+
362+
let customUser: Record<string, unknown> | null = null
363+
if (userCollection) {
364+
const customSelector = isObjectId
365+
? { [userIdField]: userId }
366+
: (emailLookup ? { email: emailLookup } : { [userIdField]: userId })
367+
customUser = await db.collection(userCollection).findOne(customSelector)
368+
if (!customUser && isObjectId) {
369+
customUser = await db.collection(userCollection).findOne({ _id: new ObjectId(userId) })
370+
}
371+
}
372+
373+
const merged: Record<string, unknown> = {
374+
...(customUser ? stripSensitiveFields(customUser) : {}),
375+
...(authUser ? stripSensitiveFields(authUser as Record<string, unknown>) : {})
376+
}
377+
378+
if (authUser && typeof (authUser as { _id?: unknown })._id !== 'undefined') {
379+
merged.id = String((authUser as { _id?: ObjectId })._id)
380+
} else if (customUser && typeof customUser[userIdField] !== 'undefined') {
381+
merged.id = String(customUser[userIdField])
382+
} else {
383+
merged.id = userId
384+
}
385+
386+
return Object.keys(merged).length ? merged : { id: userId }
387+
}
388+
251389
const readAsset = (filename: string, prefix: string) => {
252390
const candidates = resolveAssetCandidates(filename, prefix)
253391
for (const candidate of candidates) {
@@ -400,7 +538,15 @@ const createMonitoringPlugin = fp(async (
400538
const maxEvents = Math.max(1000, DEFAULT_CONFIG.MONIT_MAX_EVENTS)
401539

402540
const eventStore = createEventStore(maxAgeMs || DAY_MS, maxEvents)
541+
const functionHistory: FunctionHistoryItem[] = []
542+
const maxHistory = 30
403543
const clients = new Set<{ send: (data: string) => void; readyState: number }>()
544+
const addFunctionHistory = (entry: FunctionHistoryItem) => {
545+
functionHistory.unshift(entry)
546+
if (functionHistory.length > maxHistory) {
547+
functionHistory.splice(maxHistory)
548+
}
549+
}
404550

405551
const addEvent = (event: MonitorEvent) => {
406552
const sanitizedEvent: MonitorEvent = {
@@ -499,7 +645,7 @@ const createMonitoringPlugin = fp(async (
499645
return
500646
}
501647
}
502-
;(req as { __monitStart?: number }).__monitStart = Date.now()
648+
; (req as { __monitStart?: number }).__monitStart = Date.now()
503649
done()
504650
})
505651

@@ -647,8 +793,18 @@ const createMonitoringPlugin = fp(async (
647793
return { items }
648794
})
649795

796+
app.get(`${prefix}/api/functions/history`, async () => ({
797+
items: functionHistory.slice(0, maxHistory)
798+
}))
799+
650800
app.post(`${prefix}/api/functions/invoke`, async (req, reply) => {
651-
const body = req.body as { name?: string; arguments?: unknown[]; runAsSystem?: boolean }
801+
const body = req.body as {
802+
name?: string
803+
arguments?: unknown[]
804+
runAsSystem?: boolean
805+
userId?: string
806+
user?: Record<string, unknown>
807+
}
652808
const name = body?.name
653809
const args = Array.isArray(body?.arguments) ? body.arguments : []
654810
if (!name) {
@@ -665,21 +821,45 @@ const createMonitoringPlugin = fp(async (
665821
return { error: `Function "${name}" not found` }
666822
}
667823

824+
const resolvedUser = await resolveUserContext(app, body?.userId, body?.user)
825+
const safeArgs = (Array.isArray(args) ? sanitize(args) : sanitize([args])) as unknown[]
826+
const userInfo = resolvedUser
827+
? {
828+
id: typeof (resolvedUser as { id?: unknown }).id === 'string'
829+
? (resolvedUser as { id?: string }).id
830+
: undefined,
831+
email: typeof (resolvedUser as { email?: unknown }).email === 'string'
832+
? (resolvedUser as { email?: string }).email
833+
: undefined
834+
}
835+
: undefined
836+
addFunctionHistory({
837+
ts: Date.now(),
838+
name,
839+
args: safeArgs,
840+
runAsSystem: body?.runAsSystem !== false,
841+
user: userInfo
842+
})
843+
668844
addEvent({
669845
id: createEventId(),
670846
ts: Date.now(),
671847
type: 'function',
672848
source: 'monit',
673849
message: `invoke ${name}`,
674-
data: sanitize({ args })
850+
data: sanitize({
851+
args,
852+
user: userInfo,
853+
runAsSystem: body?.runAsSystem !== false
854+
})
675855
})
676856

677857
try {
678858
const result = await GenerateContext({
679859
args,
680860
app: appRef,
681861
rules,
682-
user: { id: 'monitor', role: 'system' },
862+
user: resolvedUser ?? { id: 'monitor', role: 'system' },
683863
currentFunction,
684864
functionsList,
685865
services,
@@ -696,7 +876,8 @@ const createMonitoringPlugin = fp(async (
696876
data: sanitize({ error })
697877
})
698878
reply.code(500)
699-
return { error: 'Function invocation failed' }
879+
const details = getErrorDetails(error)
880+
return { error: details.message, stack: details.stack }
700881
}
701882
})
702883

packages/flowerbase/src/monitoring/ui.css

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,20 +342,83 @@ button.danger {
342342
border-radius: 8px;
343343
}
344344

345+
.detail-panel {
346+
display: flex;
347+
flex-direction: column;
348+
min-height: 0;
349+
}
350+
345351
.subpanel-title {
346352
font-size: 12px;
347353
text-transform: uppercase;
348354
color: var(--muted);
349355
margin-bottom: 8px;
350356
}
351357

358+
.detail-header {
359+
display: flex;
360+
align-items: center;
361+
justify-content: space-between;
362+
gap: 8px;
363+
}
364+
365+
.detail-header .subpanel-title {
366+
margin-bottom: 0;
367+
}
368+
369+
.is-hidden {
370+
display: none;
371+
}
372+
373+
button.small {
374+
padding: 4px 8px;
375+
font-size: 11px;
376+
}
377+
352378
.user-list {
353379
flex: 1;
354380
min-height: 0;
355381
overflow: auto;
356382
font-size: 12px;
357383
}
358384

385+
.history-list {
386+
flex: 1;
387+
min-height: 0;
388+
overflow: auto;
389+
font-size: 12px;
390+
}
391+
392+
.history-row {
393+
display: grid;
394+
grid-template-columns: 1fr auto;
395+
gap: 8px;
396+
padding: 6px;
397+
border-bottom: 1px dashed rgba(26, 47, 34, 0.6);
398+
cursor: pointer;
399+
}
400+
401+
.history-row.active {
402+
background: rgba(49, 233, 129, 0.12);
403+
border-color: rgba(49, 233, 129, 0.35);
404+
}
405+
406+
.history-row:hover {
407+
background: rgba(49, 233, 129, 0.08);
408+
}
409+
410+
.history-meta {
411+
display: flex;
412+
flex-direction: column;
413+
gap: 2px;
414+
}
415+
416+
.history-empty {
417+
padding: 6px;
418+
color: var(--muted);
419+
font-size: 11px;
420+
}
421+
359422
.pager {
360423
display: flex;
361424
align-items: center;
@@ -445,6 +508,30 @@ button.danger {
445508
gap: 10px;
446509
}
447510

511+
.function-controls {
512+
display: grid;
513+
grid-template-columns: minmax(0, 2fr) minmax(120px, 1fr);
514+
gap: 10px;
515+
align-items: end;
516+
}
517+
518+
.control-group {
519+
display: flex;
520+
flex-direction: column;
521+
gap: 6px;
522+
}
523+
524+
.control-group label {
525+
font-size: 11px;
526+
text-transform: uppercase;
527+
letter-spacing: 1px;
528+
color: var(--muted);
529+
}
530+
531+
.control-group .hint {
532+
margin: 0;
533+
}
534+
448535
.function-result {
449536
flex: 1;
450537
min-height: 0;
@@ -474,4 +561,4 @@ button.danger {
474561
.users-grid {
475562
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
476563
}
477-
}
564+
}

0 commit comments

Comments
 (0)