Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions app/api/tasks/[taskId]/client-logs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { eq, and, isNull } from 'drizzle-orm'
import { getServerSession } from '@/lib/session/get-server-session'
import { redactSensitiveInfo } from '@/lib/utils/logging'

const { tasks, logEntrySchema } = schema

// Schema for the request body
const clientLogsSchema = z.object({
logs: z.array(logEntrySchema),
})

/**
* POST /api/tasks/[taskId]/client-logs
* Append client-side logs to the task's log database
*/
export async function POST(request: NextRequest, { params }: { params: Promise<{ taskId: string }> }) {
try {
const session = await getServerSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const { taskId } = await params

// Parse the request body
const body = await request.json()
const { logs } = clientLogsSchema.parse(body)

if (!logs || logs.length === 0) {
return NextResponse.json({ error: 'No logs provided' }, { status: 400 })
}

// Get the task and verify ownership (exclude soft-deleted)
const [task] = await db
.select()
.from(tasks)
.where(and(eq(tasks.id, taskId), eq(tasks.userId, session.user.id), isNull(tasks.deletedAt)))
.limit(1)

if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 })
}

// Redact sensitive information from all log messages
const sanitizedLogs = logs.map((log) => ({
...log,
message: redactSensitiveInfo(log.message),
timestamp: log.timestamp || new Date(),
}))

// Append the client logs to the existing logs
const existingLogs = task.logs || []
const updatedLogs = [...existingLogs, ...sanitizedLogs]

await db.update(tasks).set({ logs: updatedLogs, updatedAt: new Date() }).where(eq(tasks.id, taskId))

return NextResponse.json({ success: true })
} catch (error) {
console.error('Error appending client logs:', error)
return NextResponse.json({ error: 'Failed to append client logs' }, { status: 500 })
}
}
18 changes: 14 additions & 4 deletions components/logs-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ interface LogsPaneProps {
}

type TabType = 'logs' | 'terminal'
type LogFilterType = 'all' | 'platform' | 'server'
type LogFilterType = 'all' | 'platform' | 'server' | 'client'

export function LogsPane({ task, onHeightChange }: LogsPaneProps) {
const [copiedLogs, setCopiedLogs] = useState(false)
Expand Down Expand Up @@ -161,8 +161,10 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) {
const getFilteredLogs = (filter: LogFilterType) => {
return (task.logs || []).filter((log) => {
const isServerLog = log.message.startsWith('[SERVER]')
const isClientLog = log.message.startsWith('[CLIENT]')
if (filter === 'server') return isServerLog
if (filter === 'platform') return !isServerLog
if (filter === 'client') return isClientLog
if (filter === 'platform') return !isServerLog && !isClientLog
return true
})
}
Expand Down Expand Up @@ -302,6 +304,7 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) {
<SelectItem value="all">All</SelectItem>
<SelectItem value="platform">Platform</SelectItem>
<SelectItem value="server">Server</SelectItem>
<SelectItem value="client">Client</SelectItem>
</SelectContent>
</Select>
<Button
Expand Down Expand Up @@ -357,7 +360,13 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) {
>
{getFilteredLogs(logFilter).map((log, index) => {
const isServerLog = log.message.startsWith('[SERVER]')
const messageContent = isServerLog ? log.message.substring(9) : log.message // Remove '[SERVER] '
const isClientLog = log.message.startsWith('[CLIENT]')
let messageContent = log.message
if (isServerLog) {
messageContent = log.message.substring(9) // Remove '[SERVER] '
} else if (isClientLog) {
messageContent = log.message.substring(9) // Remove '[CLIENT] '
}

const getLogColor = (logType: LogEntry['type']) => {
switch (logType) {
Expand Down Expand Up @@ -388,7 +397,8 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) {
<span className="text-white/40 text-[10px] shrink-0">[{formatTime(log.timestamp || new Date())}]</span>
<span className={cn('flex-1', getLogColor(log.type))}>
{isServerLog && <span className="text-purple-400">[SERVER]</span>}
{isServerLog && ' '}
{isClientLog && <span className="text-blue-400">[CLIENT]</span>}
{(isServerLog || isClientLog) && ' '}
{messageContent}
</span>
</div>
Expand Down
11 changes: 10 additions & 1 deletion components/task-page-client.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
'use client'

import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useTask } from '@/lib/hooks/use-task'
import { useClientLogger } from '@/lib/hooks/use-client-logger'
import { TaskDetails } from '@/components/task-details'
import { TaskPageHeader } from '@/components/task-page-header'
import { PageHeader } from '@/components/page-header'
Expand Down Expand Up @@ -31,6 +32,14 @@ export function TaskPageClient({
const { task, isLoading, error } = useTask(taskId)
const { toggleSidebar } = useTasks()
const [logsPaneHeight, setLogsPaneHeight] = useState(40) // Default to collapsed height
const clientLogger = useClientLogger(taskId)

// Log when the page is loaded
useEffect(() => {
if (task && clientLogger) {
clientLogger.info('Task page loaded in browser')
}
}, [task, clientLogger])

if (isLoading) {
return (
Expand Down
75 changes: 75 additions & 0 deletions lib/hooks/use-client-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useEffect, useRef, useCallback } from 'react'
import { createClientLogger, ClientLogger } from '@/lib/utils/client-logger'

/**
* Hook that creates a client logger for a task and provides helper methods
* The logger automatically batches and sends logs to the server
*/
export function useClientLogger(taskId: string | null | undefined) {
const loggerRef = useRef<ClientLogger | null>(null)

// Create logger when taskId is available
useEffect(() => {
if (taskId && !loggerRef.current) {
loggerRef.current = createClientLogger(taskId)
}

// Cleanup: flush any pending logs when component unmounts
return () => {
if (loggerRef.current) {
loggerRef.current.flush()
}
}
}, [taskId])

// Helper methods that safely call the logger
const info = useCallback(
(message: string) => {
if (loggerRef.current) {
loggerRef.current.info(message)
}
},
[loggerRef],
)

const command = useCallback(
(message: string) => {
if (loggerRef.current) {
loggerRef.current.command(message)
}
},
[loggerRef],
)

const error = useCallback(
(message: string) => {
if (loggerRef.current) {
loggerRef.current.error(message)
}
},
[loggerRef],
)

const success = useCallback(
(message: string) => {
if (loggerRef.current) {
loggerRef.current.success(message)
}
},
[loggerRef],
)

const flush = useCallback(() => {
if (loggerRef.current) {
loggerRef.current.flush()
}
}, [loggerRef])

return {
info,
command,
error,
success,
flush,
}
Comment on lines +68 to +74
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hook returns a new object literal on every render, causing the object reference to change even though the contained functions are memoized. This will cause the clientLogger dependency in task-page-client.tsx to change on every render, leading to the "Task page loaded in browser" log being recorded multiple times instead of just once.

View Details
📝 Patch Details
diff --git a/lib/hooks/use-client-logger.ts b/lib/hooks/use-client-logger.ts
index 365481f..b754fca 100644
--- a/lib/hooks/use-client-logger.ts
+++ b/lib/hooks/use-client-logger.ts
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useCallback } from 'react'
+import { useEffect, useRef, useCallback, useMemo } from 'react'
 import { createClientLogger, ClientLogger } from '@/lib/utils/client-logger'
 
 /**
@@ -65,11 +65,11 @@ export function useClientLogger(taskId: string | null | undefined) {
     }
   }, [loggerRef])
 
-  return {
+  return useMemo(() => ({
     info,
     command,
     error,
     success,
     flush,
-  }
+  }), [info, command, error, success, flush])
 }

Analysis

Object reference changes on every render causes repeated logging in task-page-client

What fails: useClientLogger() returns a new object literal on every render, causing the clientLogger dependency in task-page-client.tsx to change reference every render, which triggers the useEffect to run repeatedly instead of once.

How to reproduce:

// In task-page-client.tsx:
const clientLogger = useClientLogger(taskId)
useEffect(() => {
  if (task && clientLogger) {
    clientLogger.info('Task page loaded in browser')  // Logs multiple times
  }
}, [task, clientLogger])  // clientLogger reference changes every render

This causes the "Task page loaded in browser" message to be logged to the server multiple times when the page loads, instead of just once as intended.

Expected behavior: Per React's useEffect dependency documentation, dependency arrays use reference equality. React's official guidance states that when an object needs to remain stable between renders to prevent Effects from firing excessively, it should be wrapped in useMemo.

Fix: Wrap the returned object in useMemo to maintain referential equality across renders when the contained callback functions haven't changed:

  • Added useMemo to imports
  • Wrapped the return statement with useMemo and dependencies [info, command, error, success, flush]
  • Since all callbacks depend only on the stable loggerRef, the memoized object maintains identity across renders

}
129 changes: 129 additions & 0 deletions lib/utils/client-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Client-side logger that sends logs to the server for storage
* All logs are prefixed with [CLIENT] and stored in the task's log database
*/

import { LogEntry } from '@/lib/db/schema'

export class ClientLogger {
private taskId: string
private batchQueue: LogEntry[] = []
private batchTimeout: NodeJS.Timeout | null = null
private readonly BATCH_DELAY = 500 // ms
private readonly MAX_BATCH_SIZE = 10

constructor(taskId: string) {
this.taskId = taskId
}

/**
* Send logs to the server
*/
private async sendToServer(logs: LogEntry[]): Promise<void> {
try {
const response = await fetch(`/api/tasks/${this.taskId}/client-logs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ logs }),
})

if (!response.ok) {
console.error('Failed to send client logs to server')
}
} catch (error) {
console.error('Error sending client logs:', error)
}
}

/**
* Flush the batch queue immediately
*/
private flushBatch(): void {
if (this.batchTimeout) {
clearTimeout(this.batchTimeout)
this.batchTimeout = null
}

if (this.batchQueue.length > 0) {
const logsToSend = [...this.batchQueue]
this.batchQueue = []
this.sendToServer(logsToSend)
}
}

/**
* Add a log entry to the batch queue
*/
private enqueue(type: LogEntry['type'], message: string): void {
const logEntry: LogEntry = {
type,
message: `[CLIENT] ${message}`,
timestamp: new Date(),
}

this.batchQueue.push(logEntry)

// Flush immediately if batch size reached
if (this.batchQueue.length >= this.MAX_BATCH_SIZE) {
this.flushBatch()
return
}

// Otherwise, schedule a batch flush
if (this.batchTimeout) {
clearTimeout(this.batchTimeout)
}

this.batchTimeout = setTimeout(() => {
this.flushBatch()
}, this.BATCH_DELAY)
}

/**
* Log an info message
*/
info(message: string): void {
this.enqueue('info', message)
console.log(`[CLIENT] ${message}`)
}

/**
* Log a command
*/
command(message: string): void {
this.enqueue('command', message)
console.log(`[CLIENT] $ ${message}`)
}

/**
* Log an error message
*/
error(message: string): void {
this.enqueue('error', message)
console.error(`[CLIENT] ${message}`)
}

/**
* Log a success message
*/
success(message: string): void {
this.enqueue('success', message)
console.log(`[CLIENT] ✓ ${message}`)
}

/**
* Flush any pending logs immediately
*/
flush(): void {
this.flushBatch()
}
}

/**
* Create a client logger instance for a specific task
*/
export function createClientLogger(taskId: string): ClientLogger {
return new ClientLogger(taskId)
}
Loading