feat: add agent response logging

This commit is contained in:
2026-07-04 17:33:28 +01:00
parent 7eacbfd846
commit e620fe636a
10 changed files with 501 additions and 101 deletions

View File

@@ -33,6 +33,8 @@
"drizzle-orm": "^0.45.1",
"hono": "^4",
"lodash.merge": "^4.6.2",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"typebox": "^1.1.38"
},
"devDependencies": {

View File

@@ -10,11 +10,13 @@ import { type } from "arktype"
import type { ConversationStorage } from "../conversations/storage"
import type { Job } from "../lib/job"
import type { AppLogger } from "../lib/logger"
import type { JobExecutor } from "../lib/worker"
import type { NotificationCentral } from "../notification/notification-central"
import type { UserSessionManager } from "../session"
import { ConversationResponseStateStatus } from "../db/schema"
import { logger as rootLogger } from "../lib/logger"
import {
AgentResponseStreamEventKind,
streamAgentResponse,
@@ -27,61 +29,93 @@ export interface AgentResponseJobPayload {
interface AgentResponseWorkerConfig {
conversationStorage: ConversationStorage
logger?: AppLogger
userSessionManager: UserSessionManager
notificationCentral: NotificationCentral
}
export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPayload> {
private conversationStorage: ConversationStorage
private logger: AppLogger
private userSessionManager: UserSessionManager
private notificationCentral: NotificationCentral
constructor({
conversationStorage,
logger,
userSessionManager,
notificationCentral,
}: AgentResponseWorkerConfig) {
this.conversationStorage = conversationStorage
this.logger = logger ?? rootLogger.child({ component: "agent_response_job" })
this.userSessionManager = userSessionManager
this.notificationCentral = notificationCentral
}
async execute(job: Job<AgentResponseJobPayload>): Promise<void> {
const conversation = await this.conversationStorage.findConversation(job.payload.conversationId)
const conversationId = job.payload.conversationId
const startedAt = Date.now()
const logger = this.logger.child({ conversationId, jobId: job.id })
logger.info("agent response job started")
const conversation = await this.conversationStorage.findConversation(conversationId)
if (!conversation) {
logger.warn("conversation not found for agent response job")
return
}
const claimed = await this.conversationStorage.claimPendingConversationResponseState(
job.payload.conversationId,
)
const claimed =
await this.conversationStorage.claimPendingConversationResponseState(conversationId)
if (!claimed) {
// conversation response state not found or already claimed
logger.debug("agent response state was not claimable")
return
}
const pendingEntries = await this.conversationStorage.listPendingUserConversationEntries(
conversation.userId,
conversation.id,
)
if (pendingEntries.length === 0) {
await this.conversationStorage.clearConversationResponseState(job.payload.conversationId)
return
}
const message = pendingEntries.reduce((acc, entry) => {
const payload = UserMessagePayload(entry.payload)
if (payload instanceof type.errors) {
return acc
}
return (
acc + "\n" + payload.parts.reduce((msg, p) => (p.type === "text" ? msg + p.text : msg), "")
)
}, "")
const session = await this.userSessionManager.getOrCreate(conversation.userId)
try {
const pendingEntries = await this.conversationStorage.listPendingUserConversationEntries(
conversation.userId,
conversation.id,
)
if (pendingEntries.length === 0) {
await this.conversationStorage.clearConversationResponseState(conversationId)
logger.debug("agent response state cleared with no pending user entries")
return
}
let invalidPendingEntryCount = 0
const message = pendingEntries.reduce((acc, entry) => {
const payload = UserMessagePayload(entry.payload)
if (payload instanceof type.errors) {
invalidPendingEntryCount += 1
return acc
}
return (
acc +
"\n" +
payload.parts.reduce((msg, p) => (p.type === "text" ? msg + p.text : msg), "")
)
}, "")
logger.debug(
{
invalidPendingEntryCount,
messageLength: message.length,
pendingEntryCount: pendingEntries.length,
},
"agent response pending user entries loaded",
)
if (invalidPendingEntryCount > 0) {
logger.warn(
{ invalidPendingEntryCount },
"invalid pending user entries skipped for agent response",
)
}
const session = await this.userSessionManager.getOrCreate(conversation.userId)
for await (const event of streamAgentResponse({
agent: session.agent,
input: {
@@ -94,21 +128,36 @@ export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPay
break
}
await this.handleStreamEvent(event, conversation.id, conversation.userId)
await this.handleStreamEvent(event, conversation.id, conversation.userId, logger)
}
// if job is aborted, stop everything immediately, including clean up.
// the aborter is assumed responsibility on how to proceed.
if (!job.signal.aborted) {
await this.conversationStorage.clearConversationResponseState(job.payload.conversationId)
await this.conversationStorage.clearConversationResponseState(conversationId)
logger.info({ durationMs: Date.now() - startedAt }, "agent response job completed")
} else {
logger.warn({ durationMs: Date.now() - startedAt }, "agent response job aborted")
}
} catch (err) {
console.error("[agent job executor] error streaming agent response:", err)
if (!job.signal.aborted) {
const durationMs = Date.now() - startedAt
if (job.signal.aborted) {
logger.warn({ durationMs, err }, "agent response job aborted")
return
}
logger.error({ durationMs, err }, "agent response job failed")
try {
await this.conversationStorage.markResponseStateStatus(
[job.payload.conversationId],
[conversationId],
ConversationResponseStateStatus.Failed,
)
logger.warn(
{ status: ConversationResponseStateStatus.Failed },
"conversation response state marked failed",
)
} catch (markErr) {
logger.error({ err: markErr }, "failed to mark conversation response state failed")
}
}
}
@@ -117,6 +166,7 @@ export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPay
event: AgentResponseStreamEvent,
conversationId: string,
userId: string,
logger: AppLogger,
): Promise<void> {
switch (event.kind) {
case AgentResponseStreamEventKind.ConversationStarted:
@@ -127,6 +177,13 @@ export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPay
conversationId: event.conversationId,
},
})
logger.debug(
{
eventKind: event.kind,
upstreamConversationId: event.conversationId,
},
"agent conversation started",
)
break
case AgentResponseStreamEventKind.AssistantMessage: {
@@ -144,6 +201,14 @@ export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPay
entry,
},
})
logger.debug(
{
entryId: entry.id,
eventKind: event.kind,
textLength: event.text.length,
},
"agent assistant message persisted",
)
break
}
@@ -161,6 +226,14 @@ export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPay
entry,
},
})
logger.debug(
{
entryId: entry.id,
eventKind: event.kind,
toolName: event.toolName,
},
"agent tool call persisted",
)
break
}
@@ -179,6 +252,15 @@ export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPay
entry,
},
})
logger.debug(
{
entryId: entry.id,
eventKind: event.kind,
ok: event.ok,
toolName: event.toolName,
},
"agent tool result persisted",
)
break
}
@@ -190,6 +272,7 @@ export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPay
conversationId,
},
})
logger.info({ eventKind: event.kind }, "agent response finished")
break
case AgentResponseStreamEventKind.ResponseFailed:
@@ -201,6 +284,10 @@ export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPay
error: event.error,
},
})
logger.warn(
{ agentError: event.error, eventKind: event.kind },
"agent response failed event received",
)
break
}
}

View File

@@ -1,9 +1,13 @@
import type { ConversationStorage } from "../conversations/storage"
import type { AppLogger } from "../lib/logger"
import type { AgentWorkScheduler } from "./scheduler"
import { logger as rootLogger } from "../lib/logger"
interface AgentResponseReconcilerConfig {
storage: ConversationStorage
interval: number
logger?: AppLogger
scheduler: AgentWorkScheduler
signal: AbortSignal
}
@@ -11,20 +15,23 @@ interface AgentResponseReconcilerConfig {
export class AgentResponseReconciler {
private storage: ConversationStorage
private interval: number
private logger: AppLogger
private scheduler: AgentWorkScheduler
private signal: AbortSignal
private stopLoop: ReturnType<typeof setInterval> | null = null
constructor({ storage, interval, scheduler, signal }: AgentResponseReconcilerConfig) {
constructor({ storage, interval, logger, scheduler, signal }: AgentResponseReconcilerConfig) {
this.storage = storage
this.interval = interval
this.logger = logger ?? rootLogger.child({ component: "agent_reconciler" })
this.scheduler = scheduler
this.signal = signal
}
start() {
this.signal.throwIfAborted()
this.logger.debug({ intervalMs: this.interval }, "agent response reconciler started")
this.signal.addEventListener(
"abort",
@@ -32,39 +39,62 @@ export class AgentResponseReconciler {
if (this.stopLoop !== null) {
clearInterval(this.stopLoop)
this.stopLoop = null
this.logger.debug("agent response reconciler stopped")
}
},
{ once: true },
)
this.stopLoop = setInterval(this.reconcile.bind(this), this.interval)
this.stopLoop = setInterval(() => {
void this.reconcile()
}, this.interval)
}
private async reconcile() {
// enqueue pending responses
const pendingStates = await this.storage.listPendingResponseStates()
const now = new Date().getTime()
for (const state of pendingStates) {
if (state.maxWaitUntil.getTime() < now) {
this.scheduler.enqueueAgentResponse(state.conversationId)
try {
// enqueue pending responses
const pendingStates = await this.storage.listPendingResponseStates()
const now = new Date().getTime()
this.logger.debug({ pendingCount: pendingStates.length }, "agent response reconcile tick")
for (const state of pendingStates) {
if (state.maxWaitUntil.getTime() < now) {
this.logger.info(
{
conversationId: state.conversationId,
maxWaitUntil: state.maxWaitUntil.toISOString(),
},
"pending agent response reached max wait",
)
this.scheduler.enqueueAgentResponse(state.conversationId)
}
}
}
// re-enqueue stuck responses
const runningStates = await this.storage.listRunningResponseStates()
const stuckIds: string[] = []
for (const state of runningStates) {
if (state.runningSince && Math.max(now - state.runningSince.getTime(), 0) > 5 * 1000 * 60) {
// if the response is running for more than 5 minutes
// we assume that its stuck and enqueue it for retry
stuckIds.push(state.conversationId)
// re-enqueue stuck responses
const runningStates = await this.storage.listRunningResponseStates()
const stuckIds: string[] = []
for (const state of runningStates) {
if (state.runningSince && Math.max(now - state.runningSince.getTime(), 0) > 5 * 1000 * 60) {
// if the response is running for more than 5 minutes
// we assume that its stuck and enqueue it for retry
stuckIds.push(state.conversationId)
}
}
}
if (stuckIds.length > 0) {
await this.storage.markResponseStateStatus(stuckIds, "pending")
for (const id of stuckIds) {
this.scheduler.enqueueAgentResponse(id)
this.logger.debug(
{ runningCount: runningStates.length, stuckCount: stuckIds.length },
"agent response running states reconciled",
)
if (stuckIds.length > 0) {
await this.storage.markResponseStateStatus(stuckIds, "pending")
this.logger.warn(
{ conversationIds: stuckIds, count: stuckIds.length },
"stuck agent responses requeued",
)
for (const id of stuckIds) {
this.scheduler.enqueueAgentResponse(id)
}
}
} catch (err) {
this.logger.error({ err }, "agent response reconciliation failed")
}
}
}

View File

@@ -2,10 +2,19 @@ import type { UserEvent } from "@freya/agent-protocol"
import type { ConversationStorage } from "../conversations/storage"
import type { Job, JobRegistry } from "../lib/job"
import type { AppLogger } from "../lib/logger"
import type { AgentResponseJobPayload } from "./job"
import { ConversationNotFoundError } from "../conversations/errors"
import { ConversationResponseStateStatus } from "../db/schema"
import { logger as rootLogger } from "../lib/logger"
const AgentJobCancellationReason = {
NewUserActivity: "new_user_activity",
SupersededByEnqueue: "superseded_by_enqueue",
} as const
type AgentJobCancellationReason =
(typeof AgentJobCancellationReason)[keyof typeof AgentJobCancellationReason]
interface AgentMessageSchedulerConfig {
storage: ConversationStorage
@@ -14,9 +23,10 @@ interface AgentMessageSchedulerConfig {
/**
* How long to wait before responding to the user.
*/
waitTIme: number
waitTime: number
jobRegistry: JobRegistry<AgentResponseJobPayload>
logger?: AppLogger
}
/**
@@ -25,6 +35,7 @@ interface AgentMessageSchedulerConfig {
export class AgentWorkScheduler {
private conversationStorage: ConversationStorage
private jobRegistry: JobRegistry<AgentResponseJobPayload>
private logger: AppLogger
private timing: {
maxWaitTime: number
@@ -37,9 +48,10 @@ export class AgentWorkScheduler {
constructor(config: AgentMessageSchedulerConfig) {
this.conversationStorage = config.storage
this.jobRegistry = config.jobRegistry
this.logger = config.logger ?? rootLogger.child({ component: "agent_scheduler" })
this.timing = {
maxWaitTime: config.maxWaitTime,
waitTime: config.waitTIme,
waitTime: config.waitTime,
}
this.jobRegistry.addEventListener("settled", this.eraseJob.bind(this))
@@ -50,9 +62,10 @@ export class AgentWorkScheduler {
const existing = this.timers.get(conversationId)
if (existing) {
clearTimeout(existing)
this.logger.debug({ conversationId }, "existing agent response timer replaced")
}
this.cancelCurrentJob(conversationId)
this.cancelCurrentJob(conversationId, AgentJobCancellationReason.NewUserActivity)
this.timers.set(
conversationId,
@@ -60,11 +73,22 @@ export class AgentWorkScheduler {
this.enqueueAgentResponse(conversationId)
}, this.timing.waitTime),
)
this.logger.info(
{
conversationId,
maxWaitMs: this.timing.maxWaitTime,
waitMs: this.timing.waitTime,
},
"agent response scheduled",
)
}
async receiveUserEvent(conversationId: string, event: UserEvent) {
if (event.type === "typing") {
this.logger.debug({ conversationId, eventType: event.type }, "user event received")
await this.delayAgentResponse(conversationId)
this.logger.debug({ conversationId }, "agent response delay handled")
}
}
@@ -73,18 +97,20 @@ export class AgentWorkScheduler {
if (existing) {
clearTimeout(existing)
this.timers.delete(conversationId)
this.logger.debug({ conversationId }, "agent response timer consumed")
}
this.cancelCurrentJob(conversationId)
this.cancelCurrentJob(conversationId, AgentJobCancellationReason.SupersededByEnqueue)
const job = this.jobRegistry.addJob({
payload: { conversationId },
})
this.runningJobs.set(conversationId, job)
this.logger.info({ conversationId, jobId: job.id }, "agent response job enqueued")
}
private async delayAgentResponse(conversationId: string) {
this.cancelCurrentJob(conversationId)
this.cancelCurrentJob(conversationId, AgentJobCancellationReason.NewUserActivity)
try {
const ok = await this.conversationStorage.transaction(async (storage) => {
@@ -102,14 +128,20 @@ export class AgentWorkScheduler {
})
if (ok) {
await this.scheduleAgentResponse(conversationId)
} else {
this.logger.debug(
{ conversationId },
"agent response delay skipped because response state is not pending",
)
}
} catch (error) {
if (error instanceof ConversationNotFoundError) {
// the user is typing but there isn't a scheduled agent response yet
// which means the user is typing their first message after the agent has previously responded
// swallow the error
this.logger.debug({ conversationId }, "typing event received without active conversation")
} else {
console.error("[agent response scheduler] error delaying agent response", error)
this.logger.error({ err: error, conversationId }, "agent response delay failed")
}
return
}
@@ -119,18 +151,26 @@ export class AgentWorkScheduler {
* cancels the current job for agent response for the given conversation id
* no-op if there is no active job for the conversation.
*/
private cancelCurrentJob(conversationId: string): void {
private cancelCurrentJob(conversationId: string, reason: AgentJobCancellationReason): void {
const job = this.runningJobs.get(conversationId)
if (!job) return
// If an active response is working on stale context, abort it so the next
// job can answer using the latest pending user messages.
this.jobRegistry.cancelJob(job)
this.logger.info(
{ conversationId, jobId: job.id, reason },
"active agent response job cancelled",
)
}
private eraseJob(job: Job<AgentResponseJobPayload>) {
if (this.runningJobs.get(job.payload.conversationId) === job) {
this.runningJobs.delete(job.payload.conversationId)
this.logger.debug(
{ conversationId: job.payload.conversationId, jobId: job.id },
"agent response job tracking cleared",
)
}
}
}

View File

@@ -3,10 +3,12 @@ import type { UserEvent } from "@freya/agent-protocol"
import { ConversationEntry, ConversationEntryKind, UserMessagePayload } from "@freya/core"
import type { ConversationStorage } from "../conversations/storage"
import type { AppLogger } from "../lib/logger"
import type { NotificationCentral } from "../notification/notification-central"
import type { UserSessionManager } from "../session"
import { JobRegistry } from "../lib/job"
import { logger as rootLogger } from "../lib/logger"
import { Worker } from "../lib/worker"
import { AgentResponseJobExecutor, type AgentResponseJobPayload } from "./job"
import { AgentResponseReconciler } from "./reconciler"
@@ -22,74 +24,129 @@ interface AgentServiceConfig {
userSessionManager: UserSessionManager
notificationCentral: NotificationCentral
signal: AbortSignal
logger?: AppLogger
}
export class AgentService {
private readonly storage: ConversationStorage
private readonly logger: AppLogger
private readonly scheduler: AgentWorkScheduler
private readonly reconciler: AgentResponseReconciler
private readonly worker: Worker<AgentResponseJobPayload>
private readonly jobRegistry = new JobRegistry<AgentResponseJobPayload>()
constructor({ storage, userSessionManager, notificationCentral, signal }: AgentServiceConfig) {
constructor({
storage,
userSessionManager,
notificationCentral,
signal,
logger,
}: AgentServiceConfig) {
const baseLogger = logger ?? rootLogger
this.storage = storage
this.logger = baseLogger.child({ component: "agent_service" })
this.scheduler = new AgentWorkScheduler({
storage,
jobRegistry: this.jobRegistry,
waitTIme: AgentResponseTiming.waitTime,
waitTime: AgentResponseTiming.waitTime,
maxWaitTime: AgentResponseTiming.maxWaitTime,
logger: baseLogger.child({ component: "agent_scheduler" }),
})
this.reconciler = new AgentResponseReconciler({
signal,
storage: this.storage,
interval: 60 * 1000,
scheduler: this.scheduler,
logger: baseLogger.child({ component: "agent_reconciler" }),
})
this.worker = new Worker<AgentResponseJobPayload>({
signal,
concurrency: 10,
registry: this.jobRegistry,
logger: baseLogger.child({ component: "agent_worker" }),
jobFields: agentResponseJobFields,
runner: new AgentResponseJobExecutor({
conversationStorage: storage,
notificationCentral,
userSessionManager,
logger: baseLogger.child({ component: "agent_response_job" }),
}),
})
}
start() {
this.logger.info("agent service starting")
this.worker.start()
this.reconciler.start()
}
async scheduleAgentResponse(conversationId: string, message: string): Promise<ConversationEntry> {
const createdEntry = await this.storage.transaction(async (storage) => {
const now = new Date()
this.logger.info(
{ conversationId, messageLength: message.length },
"scheduling agent response from user message",
)
const entry = await storage.appendEntry(conversationId, {
kind: ConversationEntryKind.UserMessage,
payload: {
role: "user",
parts: [{ type: "text", text: message }],
} satisfies UserMessagePayload,
try {
const scheduledResponse = await this.storage.transaction(async (storage) => {
const now = new Date()
const maxWaitUntil = new Date(now.getTime() + AgentResponseTiming.maxWaitTime)
const entry = await storage.appendEntry(conversationId, {
kind: ConversationEntryKind.UserMessage,
payload: {
role: "user",
parts: [{ type: "text", text: message }],
} satisfies UserMessagePayload,
})
await storage.upsertConversationResponseState(conversationId, {
maxWaitUntil,
pendingSinceEntryId: entry.id,
status: "pending",
})
return { entry, maxWaitUntil }
})
await storage.upsertConversationResponseState(conversationId, {
maxWaitUntil: new Date(now.getTime() + AgentResponseTiming.maxWaitTime),
pendingSinceEntryId: entry.id,
status: "pending",
})
this.logger.info(
{
conversationId,
entryId: scheduledResponse.entry.id,
maxWaitMs: AgentResponseTiming.maxWaitTime,
maxWaitUntil: scheduledResponse.maxWaitUntil.toISOString(),
waitMs: AgentResponseTiming.waitTime,
},
"agent response state set pending",
)
return entry
})
await this.scheduler.scheduleAgentResponse(conversationId)
await this.scheduler.scheduleAgentResponse(conversationId)
return createdEntry
return scheduledResponse.entry
} catch (err) {
this.logger.error({ err, conversationId }, "failed to schedule agent response")
throw err
}
}
async handleUserEvent(conversationId: string, event: UserEvent) {
await this.scheduler.receiveUserEvent(conversationId, event)
try {
await this.scheduler.receiveUserEvent(conversationId, event)
} catch (err) {
this.logger.error(
{ err, conversationId, eventType: event.type },
"failed to handle user event",
)
throw err
}
}
}
function agentResponseJobFields(job: {
payload: AgentResponseJobPayload
}): Record<string, unknown> {
return {
conversationId: job.payload.conversationId,
}
}

View File

@@ -9,12 +9,17 @@ import { upgradeWebSocket, websocket } from "hono/bun"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { ConversationStorage } from "../conversations/storage.ts"
import type { AppLogger } from "../lib/logger.ts"
import type {
NotificationCentral,
NotificationPayload,
} from "../notification/notification-central.ts"
import type { AgentService } from "./service.ts"
import { logger } from "../lib/logger.ts"
const agentWebSocketLogger = logger.child({ component: "agent_ws" })
interface AgentWebSocketHandlerDeps {
agentService: AgentService
storage: ConversationStorage
@@ -60,7 +65,10 @@ export function registerAgentWebSocketHandlers(
onOpen(_event, ws) {
channel.attach(ws)
void connection.start().catch((err: unknown) => {
console.error("[query] Agent WebSocket JSON-RPC failed:", errorMessage(err))
agentWebSocketLogger.error(
{ err, conversationId: conversation.id },
"agent websocket JSON-RPC failed",
)
ws.close(1011, "Agent RPC connection failed")
})
},
@@ -82,6 +90,7 @@ class AgentRpcConnection implements AgentServerApi {
private readonly client: JsonRpcClient<AgentClientApi>
private readonly server: JsonRpcServer<AgentServerApi>
private readonly agentService: AgentService
private readonly logger: AppLogger
private readonly notificationCentral: NotificationCentral
private readonly userId: string
private readonly conversationId: string
@@ -103,6 +112,7 @@ class AgentRpcConnection implements AgentServerApi {
}) {
this.client = new JsonRpcClient<AgentClientApi>(channel)
this.agentService = agentService
this.logger = agentWebSocketLogger.child({ conversationId })
this.notificationCentral = notificationCentral
this.userId = userId
this.conversationId = conversationId
@@ -117,14 +127,19 @@ class AgentRpcConnection implements AgentServerApi {
}
notify(event: UserEvent): void {
this.agentService.handleUserEvent(this.conversationId, event)
void this.agentService.handleUserEvent(this.conversationId, event).catch((err: unknown) => {
this.logger.error({ err, eventType: event.type }, "failed to handle agent user event")
})
}
async sendMessage(message: string): Promise<ConversationEntry> {
try {
return await this.agentService.scheduleAgentResponse(this.conversationId, message)
} catch (error) {
console.error("[agent rpc connection] error when scheduling agent response", error)
this.logger.error(
{ err: error, messageLength: message.length },
"agent rpc schedule agent response failed",
)
throw error
}
}
@@ -239,7 +254,3 @@ class HonoWebSocketJrpcChannel implements JrpcChannel {
this.queue.push(msg)
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}

View File

@@ -0,0 +1,83 @@
import { hostname } from "node:os"
import pino, { type Logger } from "pino"
export type AppLogger = Logger
const LogLevel = {
Trace: "trace",
Debug: "debug",
Info: "info",
Warn: "warn",
Error: "error",
Fatal: "fatal",
Silent: "silent",
} as const
type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]
export const logger = pino({
base: {
hostname: hostname(),
pid: process.pid,
service: "freya-backend",
},
level: readLogLevel(process.env.LOG_LEVEL, process.env.NODE_ENV),
transport:
readLogFormat(process.env.LOG_FORMAT, process.env.NODE_ENV) === "pretty"
? {
target: "pino-pretty",
options: {
colorize: true,
ignore: "hostname,pid",
singleLine: false,
translateTime: "SYS:standard",
},
}
: undefined,
redact: {
censor: "[redacted]",
paths: [
"apiKey",
"authorization",
"cookie",
"credentialEncryptionKey",
"password",
"privateKey",
"*.apiKey",
"*.authorization",
"*.cookie",
"*.credentialEncryptionKey",
"*.password",
"*.privateKey",
],
},
serializers: {
err: pino.stdSerializers.err,
},
})
function readLogFormat(value: string | undefined, nodeEnv: string | undefined): "json" | "pretty" {
const normalized = value?.trim().toLowerCase()
if (normalized === "json") {
return "json"
}
if (nodeEnv === "test") {
return "json"
}
return "pretty"
}
function readLogLevel(value: string | undefined, nodeEnv: string | undefined): LogLevel {
const normalized = value?.trim().toLowerCase()
switch (normalized) {
case LogLevel.Trace:
case LogLevel.Debug:
case LogLevel.Info:
case LogLevel.Warn:
case LogLevel.Error:
case LogLevel.Fatal:
case LogLevel.Silent:
return normalized
default:
return nodeEnv === "test" ? LogLevel.Silent : LogLevel.Info
}
}

View File

@@ -1,25 +1,40 @@
import type { Job, JobRegistry } from "./job"
import type { Queue } from "./queue"
import type { AppLogger } from "./logger"
import { logger as rootLogger } from "./logger"
export interface JobExecutor<JobPayload> {
execute(job: Job<JobPayload>): Promise<void>
}
export interface WorkerConfig<Job> {
export interface WorkerConfig<JobPayload> {
concurrency: number
registry: JobRegistry<Job>
runner: JobExecutor<Job>
jobFields?: (job: Job<JobPayload>) => Record<string, unknown>
logger?: AppLogger
registry: JobRegistry<JobPayload>
runner: JobExecutor<JobPayload>
signal: AbortSignal
}
export class Worker<Job> {
export class Worker<JobPayload> {
private concurrency: number
private registry: JobRegistry<Job>
private runner: JobExecutor<Job>
private jobFields: (job: Job<JobPayload>) => Record<string, unknown>
private logger: AppLogger
private registry: JobRegistry<JobPayload>
private runner: JobExecutor<JobPayload>
private signal: AbortSignal
constructor({ concurrency, registry, runner, signal }: WorkerConfig<Job>) {
constructor({
concurrency,
jobFields,
logger,
registry,
runner,
signal,
}: WorkerConfig<JobPayload>) {
this.concurrency = concurrency
this.jobFields = jobFields ?? emptyJobFields
this.logger = logger ?? rootLogger.child({ component: "worker" })
this.registry = registry
this.runner = runner
this.signal = signal
@@ -27,25 +42,45 @@ export class Worker<Job> {
start() {
if (this.signal.aborted) return
this.logger.debug({ concurrency: this.concurrency }, "worker started")
for (let i = 0; i < this.concurrency; i++) {
void this.pollJobFromRegistry()
void this.pollJobFromRegistry(i)
}
}
private async pollJobFromRegistry() {
private async pollJobFromRegistry(workerId: number) {
while (!this.signal.aborted) {
const job = await this.registry.nextJob(this.signal)
if (!job) {
return
}
const startedAt = Date.now()
const fields = {
...this.jobFields(job),
jobId: job.id,
workerId,
}
this.logger.debug(fields, "job execution started")
try {
await this.runner.execute(job)
} catch {
// TODO: handle logging of job execution errors
this.logger.debug(
{ ...fields, durationMs: Date.now() - startedAt },
"job execution completed",
)
} catch (err) {
this.logger.error(
{ ...fields, durationMs: Date.now() - startedAt, err },
"job execution failed",
)
} finally {
this.registry.markJobAsCompleted(job)
}
}
}
}
function emptyJobFields<JobPayload>(_job: Job<JobPayload>): Record<string, unknown> {
return {}
}

View File

@@ -21,6 +21,7 @@ import { createLlmClient } from "./enhancement/llm-client.ts"
import { GoogleMapsSourceProvider } from "./google-maps/provider.ts"
import { CredentialEncryptor } from "./lib/crypto.ts"
import { ensureEnv } from "./lib/env.ts"
import { logger } from "./lib/logger.ts"
import { registerLocationHttpHandlers } from "./location/http.ts"
import { LocationSourceProvider } from "./location/provider.ts"
import { NotificationCentral } from "./notification/notification-central.ts"
@@ -77,7 +78,10 @@ function main() {
},
})
if (!piApiKey) {
console.warn("[query] PI_API_KEY or OPENROUTER_API_KEY not set — query agent unavailable")
logger.warn(
{ component: "query_agent" },
"PI_API_KEY or OPENROUTER_API_KEY not set; query agent unavailable",
)
}
const notificationCentral = new NotificationCentral()
@@ -87,6 +91,7 @@ function main() {
storage: conversationStorage,
userSessionManager: sessionManager,
signal: abortController.signal,
logger,
})
const app = new Hono()