From 202fbeacb656b0b6947302d424dee46f9832a38f Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Sat, 26 Jul 2025 20:05:34 +1000 Subject: [PATCH] add logging (#187) --- .github/workflows/opencode.yml | 28 ++++ apps/web/package.json | 2 + apps/web/src/app/api/ses_callback/route.ts | 1 + apps/web/src/server/api/routers/campaign.ts | 8 +- apps/web/src/server/api/routers/team.ts | 3 +- apps/web/src/server/api/trpc.ts | 25 +++- apps/web/src/server/aws/ses.ts | 11 +- apps/web/src/server/db.ts | 3 +- apps/web/src/server/jobs/usage-job.ts | 21 +-- apps/web/src/server/logger/log.ts | 78 +++++++++++ apps/web/src/server/mailer.ts | 20 +-- apps/web/src/server/public-api/api-error.ts | 34 +++-- apps/web/src/server/public-api/auth.ts | 5 +- apps/web/src/server/public-api/hono.ts | 5 +- apps/web/src/server/queue/bullmq-context.ts | 24 ++++ apps/web/src/server/service/api-service.ts | 7 +- .../src/server/service/campaign-service.ts | 37 +++-- apps/web/src/server/service/domain-service.ts | 5 +- .../src/server/service/email-queue-service.ts | 94 ++++++++----- apps/web/src/server/service/email-service.ts | 9 +- .../server/service/notification-service.ts | 12 +- .../web/src/server/service/ses-hook-parser.ts | 21 ++- .../server/service/ses-settings-service.ts | 39 ++++-- pnpm-lock.yaml | 132 +++++++++++++++++- 24 files changed, 490 insertions(+), 134 deletions(-) create mode 100644 .github/workflows/opencode.yml create mode 100644 apps/web/src/server/logger/log.ts create mode 100644 apps/web/src/server/queue/bullmq-context.ts diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml new file mode 100644 index 0000000..74e9b3d --- /dev/null +++ b/.github/workflows/opencode.yml @@ -0,0 +1,28 @@ +name: opencode + +on: + issue_comment: + types: [created] + +jobs: + opencode: + if: | + startsWith(github.event.comment.body, 'opencode') || + startsWith(github.event.comment.body, 'hi opencode') || + startsWith(github.event.comment.body, 'hey opencode') || + contains(github.event.comment.body, '@opencode-agent') + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run opencode + uses: sst/opencode/sdks/github@github-v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + model: github-copilot/claude-sonnet-4 \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index fc23208..34c1b52 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -49,6 +49,8 @@ "next": "^15.3.1", "next-auth": "^4.24.11", "nodemailer": "^7.0.3", + "pino": "^9.7.0", + "pino-pretty": "^13.0.0", "pnpm": "^10.9.0", "prisma": "^6.6.0", "query-string": "^9.1.1", diff --git a/apps/web/src/app/api/ses_callback/route.ts b/apps/web/src/app/api/ses_callback/route.ts index 4391640..70913c1 100644 --- a/apps/web/src/app/api/ses_callback/route.ts +++ b/apps/web/src/app/api/ses_callback/route.ts @@ -1,5 +1,6 @@ import { env } from "~/env"; import { db } from "~/server/db"; +import { logger } from "~/server/logger/log"; import { parseSesHook, SesHookParser } from "~/server/service/ses-hook-parser"; import { SesSettingsService } from "~/server/service/ses-settings-service"; import { SnsNotificationMessage } from "~/types/aws-types"; diff --git a/apps/web/src/server/api/routers/campaign.ts b/apps/web/src/server/api/routers/campaign.ts index 4f925b9..4a819af 100644 --- a/apps/web/src/server/api/routers/campaign.ts +++ b/apps/web/src/server/api/routers/campaign.ts @@ -9,6 +9,7 @@ import { campaignProcedure, publicProcedure, } from "~/server/api/trpc"; +import { logger } from "~/server/logger/log"; import { nanoid } from "~/server/nanoid"; import { sendCampaign, @@ -66,14 +67,15 @@ export const campaignRouter = createTRPCRouter({ let time = performance.now(); campaignsP.then((campaigns) => { - console.log( + logger.info( `Time taken to get campaigns: ${performance.now() - time} milliseconds` ); }); const [campaigns, count] = await Promise.all([campaignsP, countP]); - console.log( - `Time taken to complete request: ${performance.now() - completeTime} milliseconds` + logger.info( + { duration: performance.now() - completeTime }, + `Time taken to complete request` ); return { campaigns, totalPage: Math.ceil(count / limit) }; diff --git a/apps/web/src/server/api/routers/team.ts b/apps/web/src/server/api/routers/team.ts index 9a43bed..921add0 100644 --- a/apps/web/src/server/api/routers/team.ts +++ b/apps/web/src/server/api/routers/team.ts @@ -10,6 +10,7 @@ import { } from "~/server/api/trpc"; import { sendTeamInviteEmail } from "~/server/mailer"; import send from "~/server/public-api/api/emails/send-email"; +import { logger } from "~/server/logger/log"; export const teamRouter = createTRPCRouter({ createTeam: protectedProcedure @@ -26,7 +27,7 @@ export const teamRouter = createTRPCRouter({ }); if (teams.length > 0) { - console.log("User already has a team"); + logger.info({ userId: ctx.session.user.id }, "User already has a team"); return; } diff --git a/apps/web/src/server/api/trpc.ts b/apps/web/src/server/api/trpc.ts index e7bd521..b739875 100644 --- a/apps/web/src/server/api/trpc.ts +++ b/apps/web/src/server/api/trpc.ts @@ -14,6 +14,8 @@ import { env } from "~/env"; import { getServerAuthSession } from "~/server/auth"; import { db } from "~/server/db"; +import { getChildLogger, logger, withLogger } from "../logger/log"; +import { randomUUID } from "crypto"; /** * 1. CONTEXT @@ -118,13 +120,22 @@ export const teamProcedure = protectedProcedure.use(async ({ ctx, next }) => { if (!teamUser) { throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); } - return next({ - ctx: { - team: teamUser.team, - teamUser, - session: { ...ctx.session, user: ctx.session.user }, - }, - }); + + return withLogger( + getChildLogger({ + teamId: teamUser.team.id, + requestId: randomUUID(), + }), + async () => { + return next({ + ctx: { + team: teamUser.team, + teamUser, + session: { ...ctx.session, user: ctx.session.user }, + }, + }); + } + ); }); export const teamAdminProcedure = teamProcedure.use(async ({ ctx, next }) => { diff --git a/apps/web/src/server/aws/ses.ts b/apps/web/src/server/aws/ses.ts index 0ee5a51..3c669e8 100644 --- a/apps/web/src/server/aws/ses.ts +++ b/apps/web/src/server/aws/ses.ts @@ -17,6 +17,7 @@ import { Readable } from "stream"; import { env } from "~/env"; import { EmailContent } from "~/types"; import { nanoid } from "../nanoid"; +import { logger } from "../logger/log"; function getSesClient(region: string) { return new SESv2Client({ @@ -79,8 +80,10 @@ export async function addDomain(domain: string, region: string) { response.$metadata.httpStatusCode !== 200 || emailIdentityResponse.$metadata.httpStatusCode !== 200 ) { - console.log(response); - console.log(emailIdentityResponse); + logger.error( + { response, emailIdentityResponse }, + "Failed to create domain identity" + ); throw new Error("Failed to create domain identity"); } @@ -185,10 +188,10 @@ export async function sendRawEmail({ try { const response = await sesClient.send(command); - console.log("Email sent! Message ID:", response.MessageId); + logger.info({ messageId: response.MessageId }, "Email sent!"); return response.MessageId; } catch (error) { - console.error("Failed to send email", error); + logger.error({ err: error }, "Failed to send email"); // It's better to throw the original error or a new error with more context // throw new Error("Failed to send email"); throw error; diff --git a/apps/web/src/server/db.ts b/apps/web/src/server/db.ts index 2324803..0ebc5b4 100644 --- a/apps/web/src/server/db.ts +++ b/apps/web/src/server/db.ts @@ -1,8 +1,9 @@ import { PrismaClient } from "@prisma/client"; import { env } from "~/env"; +import { logger } from "./logger/log"; const createPrismaClient = () => { - console.log("Creating Prisma client"); + logger.info("Creating Prisma client"); const client = new PrismaClient({ log: env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], diff --git a/apps/web/src/server/jobs/usage-job.ts b/apps/web/src/server/jobs/usage-job.ts index 6812d73..7d8be25 100644 --- a/apps/web/src/server/jobs/usage-job.ts +++ b/apps/web/src/server/jobs/usage-job.ts @@ -5,6 +5,7 @@ import { getUsageDate, getUsageUinits } from "~/lib/usage"; import { sendUsageToStripe } from "~/server/billing/usage"; import { getRedis } from "~/server/redis"; import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants"; +import { logger } from "../logger/log"; const USAGE_QUEUE_NAME = "usage-reporting"; @@ -12,7 +13,6 @@ const usageQueue = new Queue(USAGE_QUEUE_NAME, { connection: getRedis(), }); -// Process usage reporting jobs const worker = new Worker( USAGE_QUEUE_NAME, async () => { @@ -51,13 +51,18 @@ const worker = new Worker( try { await sendUsageToStripe(team.stripeCustomerId, totalUsage); - console.log( - `[Usage Reporting] Reported usage for team ${team.id}, date: ${getUsageDate()}, usage: ${totalUsage}` + logger.info( + { teamId: team.id, date: getUsageDate(), usage: totalUsage }, + `[Usage Reporting] Reported usage for team` ); } catch (error) { - console.error( - `[Usage Reporting] Failed to report usage for team ${team.id}:`, - error instanceof Error ? error.message : error + logger.error( + { + err: error, + teamId: team.id, + message: error instanceof Error ? error.message : error, + }, + `[Usage Reporting] Failed to report usage for team` ); } } @@ -82,9 +87,9 @@ await usageQueue.upsertJobScheduler( ); worker.on("completed", (job) => { - console.log(`[Usage Reporting] Job ${job.id} completed`); + logger.info({ jobId: job.id }, `[Usage Reporting] Job completed`); }); worker.on("failed", (job, err) => { - console.error(`[Usage Reporting] Job ${job?.id} failed:`, err); + logger.error({ err, jobId: job?.id }, `[Usage Reporting] Job failed`); }); diff --git a/apps/web/src/server/logger/log.ts b/apps/web/src/server/logger/log.ts new file mode 100644 index 0000000..2b7c7f0 --- /dev/null +++ b/apps/web/src/server/logger/log.ts @@ -0,0 +1,78 @@ +// lib/logging.ts +import pino from "pino"; +import pinoPretty from "pino-pretty"; +import { AsyncLocalStorage } from "node:async_hooks"; + +const isDev = process.env.NODE_ENV !== "production"; + +type Store = { logger: pino.Logger }; // what we stash per request +const loggerStore = new AsyncLocalStorage(); + +export const rootLogger = pino( + { + level: process.env.LOG_LEVEL ?? (isDev ? "debug" : "info"), + base: { service: "next-app" }, + }, + isDev + ? pinoPretty({ + colorize: true, + translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l", + ignore: "pid,hostname", + }) + : undefined +); + +// Helper function to get the current logger +function getCurrentLogger(): pino.Logger { + return loggerStore.getStore()?.logger ?? rootLogger; +} + +// Create a proxy that delegates all property access to the current logger +export const logger = new Proxy( + {} as pino.Logger & { setBindings: (bindings: Record) => void }, + { + get(target, prop, receiver) { + // Handle the special setBindings method + if (prop === "setBindings") { + return (bindings: Record) => { + const store = loggerStore.getStore(); + if (!store) { + // If not in a context, just update the root logger (though this won't persist) + return; + } + + // Create a new child logger with the merged bindings + const currentLogger = store.logger; + const newLogger = currentLogger.child(bindings); + + // Update the store with the new logger + store.logger = newLogger; + }; + } + + const currentLogger = getCurrentLogger(); + const value = currentLogger[prop as keyof pino.Logger]; + + if (typeof value === "function") { + return value.bind(currentLogger); + } + + return value; + }, + } +); + +export function withLogger(child: pino.Logger, fn: () => Promise | T) { + return loggerStore.run({ logger: child }, fn); +} + +export function getChildLogger({ + teamId, + requestId, + ...rest +}: { + teamId?: number; + requestId?: string; +} & Record) { + return logger.child({ teamId, requestId, ...rest }); +} diff --git a/apps/web/src/server/mailer.ts b/apps/web/src/server/mailer.ts index a5f5f47..73a54ad 100644 --- a/apps/web/src/server/mailer.ts +++ b/apps/web/src/server/mailer.ts @@ -4,6 +4,7 @@ import { isSelfHosted } from "~/utils/common"; import { db } from "./db"; import { getDomains } from "./service/domain-service"; import { sendEmail } from "./service/email-service"; +import { logger } from "./logger/log"; let unsend: Unsend | undefined; @@ -22,7 +23,7 @@ export async function sendSignUpEmail( const { host } = new URL(url); if (env.NODE_ENV === "development") { - console.log("Sending sign in email", { email, url, token }); + logger.info({ email, url, token }, "Sending sign in email"); return; } @@ -41,7 +42,7 @@ export async function sendTeamInviteEmail( const { host } = new URL(url); if (env.NODE_ENV === "development") { - console.log("Sending team invite email", { email, url, teamName }); + logger.info({ email, url, teamName }, "Sending team invite email"); return; } @@ -59,7 +60,7 @@ async function sendMail( html: string ) { if (isSelfHosted()) { - console.log("Sending email using self hosted"); + logger.info("Sending email using self hosted"); /* Self hosted so checking if we can send using one of the available domain Assuming self hosted will have only one team @@ -67,14 +68,14 @@ async function sendMail( */ const team = await db.team.findFirst({}); if (!team) { - console.error("No team found"); + logger.error("No team found"); return; } const domains = await getDomains(team.id); if (domains.length === 0 || !domains[0]) { - console.error("No domains found"); + logger.error("No domains found"); return; } @@ -101,13 +102,12 @@ async function sendMail( }); if (resp.data) { - console.log("Email sent using unsend"); + logger.info("Email sent using unsend"); return; } else { - console.log( - "Error sending email using unsend, so fallback to resend", - resp.error?.code, - resp.error?.message + logger.error( + { code: resp.error?.code, message: resp.error?.message }, + "Error sending email using unsend, so fallback to resend" ); } } else { diff --git a/apps/web/src/server/public-api/api-error.ts b/apps/web/src/server/public-api/api-error.ts index 05470fb..f5d6a91 100644 --- a/apps/web/src/server/public-api/api-error.ts +++ b/apps/web/src/server/public-api/api-error.ts @@ -2,6 +2,7 @@ import { Context } from "hono"; import { HTTPException } from "hono/http-exception"; import { StatusCode, ContentfulStatusCode } from "hono/utils/http-status"; import { z } from "zod"; +import { logger } from "../logger/log"; const ErrorCode = z.enum([ "BAD_REQUEST", @@ -79,11 +80,10 @@ export function handleError(err: Error, c: Context): Response { */ if (err instanceof UnsendApiError) { if (err.status >= 500) { - console.error(err.message, { - name: err.name, - code: err.code, - status: err.status, - }); + logger.error( + { name: err.name, code: err.code, status: err.status, err }, + err.message + ); } return c.json( { @@ -102,10 +102,10 @@ export function handleError(err: Error, c: Context): Response { */ if (err instanceof HTTPException) { if (err.status >= 500) { - console.error("HTTPException", { - message: err.message, - status: err.status, - }); + logger.error( + { message: err.message, status: err.status, err }, + "HTTPException" + ); } const code = statusToCode(err.status); return c.json( @@ -122,12 +122,16 @@ export function handleError(err: Error, c: Context): Response { /** * We're lost here, all we can do is return a 500 and log it to investigate */ - console.error("unhandled exception", { - name: err.name, - message: err.message, - cause: err.cause, - stack: err.stack, - }); + logger.error( + { + err, + name: err.name, + message: err.message, + cause: err.cause, + stack: err.stack, + }, + "unhandled exception" + ); return c.json( { error: { diff --git a/apps/web/src/server/public-api/auth.ts b/apps/web/src/server/public-api/auth.ts index dc581f2..572c2a9 100644 --- a/apps/web/src/server/public-api/auth.ts +++ b/apps/web/src/server/public-api/auth.ts @@ -3,6 +3,7 @@ import { db } from "../db"; import { UnsendApiError } from "./api-error"; import { getTeamAndApiKey } from "../service/api-service"; import { isSelfHosted } from "~/utils/common"; +import { logger } from "../logger/log"; /** * Gets the team from the token. Also will check if the token is valid. @@ -54,7 +55,9 @@ export const getTeamFromToken = async (c: Context) => { lastUsed: new Date(), }, }) - .catch(console.error); + .catch((err) => + logger.error({ err }, "Failed to update lastUsed on API key") + ); return { ...team, apiKeyId: apiKey.id }; }; diff --git a/apps/web/src/server/public-api/hono.ts b/apps/web/src/server/public-api/hono.ts index 153a9d7..bfdd784 100644 --- a/apps/web/src/server/public-api/hono.ts +++ b/apps/web/src/server/public-api/hono.ts @@ -8,6 +8,7 @@ import { getTeamFromToken } from "~/server/public-api/auth"; import { isSelfHosted } from "~/utils/common"; import { UnsendApiError } from "./api-error"; import { Team } from "@prisma/client"; +import { logger } from "../logger/log"; // Define AppEnv for Hono context export type AppEnv = { @@ -38,7 +39,7 @@ export function getApp() { if (error instanceof UnsendApiError) { throw error; } - console.error("Error in getTeamFromToken middleware:", error); + logger.error({ err: error }, "Error in getTeamFromToken middleware"); throw new UnsendApiError({ code: "INTERNAL_SERVER_ERROR", message: "Authentication failed", @@ -84,7 +85,7 @@ export function getApp() { // We rely on expire being set for new keys. ttl = await redis.ttl(key); } catch (error) { - console.error("Redis error during rate limiting:", error); + logger.error({ err: error }, "Redis error during rate limiting"); // Alternatively, you could fail closed by throwing an error here. return next(); } diff --git a/apps/web/src/server/queue/bullmq-context.ts b/apps/web/src/server/queue/bullmq-context.ts new file mode 100644 index 0000000..0c8b94e --- /dev/null +++ b/apps/web/src/server/queue/bullmq-context.ts @@ -0,0 +1,24 @@ +import { randomUUID } from "crypto"; +import { getChildLogger, withLogger } from "../logger/log"; +import { Job } from "bullmq"; + +export type TeamJob = Job; + +/** + * Simple wrapper function for BullMQ worker jobs with team context + */ +export function createWorkerHandler( + handler: (job: TeamJob) => Promise +) { + return async (job: TeamJob) => { + return await withLogger( + getChildLogger({ + teamId: job.data.teamId, + queueId: job.id ?? randomUUID(), + }), + async () => { + return await handler(job); + } + ); + }; +} diff --git a/apps/web/src/server/service/api-service.ts b/apps/web/src/server/service/api-service.ts index 2d0d17a..6d43687 100644 --- a/apps/web/src/server/service/api-service.ts +++ b/apps/web/src/server/service/api-service.ts @@ -3,6 +3,7 @@ import { db } from "../db"; import { randomBytes } from "crypto"; import { smallNanoid } from "../nanoid"; import { createSecureHash, verifySecureHash } from "../crypto"; +import { logger } from "../logger/log"; export async function addApiKey({ name, @@ -32,7 +33,7 @@ export async function addApiKey({ }); return apiKey; } catch (error) { - console.error("Error adding API key:", error); + logger.error({ err: error }, "Error adding API key"); throw error; } } @@ -64,7 +65,7 @@ export async function getTeamAndApiKey(apiKey: string) { return { team, apiKey: apiKeyRow }; } catch (error) { - console.error("Error verifying API key:", error); + logger.error({ err: error }, "Error verifying API key"); return null; } } @@ -77,7 +78,7 @@ export async function deleteApiKey(id: number) { }, }); } catch (error) { - console.error("Error deleting API key:", error); + logger.error({ err: error }, "Error deleting API key"); throw error; } } diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts index dbfb783..791c213 100644 --- a/apps/web/src/server/service/campaign-service.ts +++ b/apps/web/src/server/service/campaign-service.ts @@ -16,6 +16,8 @@ import { CAMPAIGN_MAIL_PROCESSING_QUEUE, DEFAULT_QUEUE_OPTIONS, } from "../queue/queue-constants"; +import { logger } from "../logger/log"; +import { createWorkerHandler, TeamJob } from "../queue/bullmq-context"; export async function sendCampaign(id: string) { let campaign = await db.campaign.findUnique({ @@ -41,7 +43,7 @@ export async function sendCampaign(id: string) { data: { html }, }); } catch (error) { - console.error(error); + logger.error({ err: error }, "Failed to parse campaign content"); throw new Error("Failed to parse campaign content"); } @@ -158,7 +160,7 @@ export async function unsubscribeContact({ return contact; } catch (error) { - console.error("Error unsubscribing contact:", error); + logger.error({ err: error }, "Error unsubscribing contact"); throw new Error("Failed to unsubscribe contact"); } } @@ -207,7 +209,7 @@ export async function subscribeContact(id: string, hash: string) { return true; } catch (error) { - console.error("Error subscribing contact:", error); + logger.error({ err: error }, "Error subscribing contact"); throw new Error("Failed to subscribe contact"); } } @@ -242,6 +244,8 @@ type CampaignEmailJob = { }; }; +type QueueCampaignEmailJob = TeamJob; + async function processContactEmail(jobData: CampaignEmailJob) { const { contact, campaign, emailConfig } = jobData; const jsonContent = JSON.parse(campaign.content || "{}"); @@ -282,6 +286,7 @@ async function processContactEmail(jobData: CampaignEmailJob) { // Queue email for sending await EmailQueueService.queueEmail( email.id, + emailConfig.teamId, emailConfig.region, false, unsubscribeUrl @@ -306,7 +311,7 @@ export async function sendCampaignEmail( const domain = await validateDomainFromEmail(from, teamId); - console.log("Bulk queueing contacts"); + logger.info("Bulk queueing contacts"); await CampaignEmailService.queueBulkContacts( contacts.map((contact) => ({ @@ -382,15 +387,19 @@ export async function updateCampaignAnalytics( const CAMPAIGN_EMAIL_CONCURRENCY = 50; class CampaignEmailService { - private static campaignQueue = new Queue(CAMPAIGN_MAIL_PROCESSING_QUEUE, { - connection: getRedis(), - }); + private static campaignQueue = new Queue( + CAMPAIGN_MAIL_PROCESSING_QUEUE, + { + connection: getRedis(), + } + ); + // TODO: Add team context to job data when queueing static worker = new Worker( CAMPAIGN_MAIL_PROCESSING_QUEUE, - async (job) => { + createWorkerHandler(async (job: QueueCampaignEmailJob) => { await processContactEmail(job.data); - }, + }), { connection: getRedis(), concurrency: CAMPAIGN_EMAIL_CONCURRENCY, @@ -400,7 +409,10 @@ class CampaignEmailService { static async queueContact(data: CampaignEmailJob) { return await this.campaignQueue.add( `contact-${data.contact.id}`, - data, + { + ...data, + teamId: data.emailConfig.teamId, + }, DEFAULT_QUEUE_OPTIONS ); } @@ -409,7 +421,10 @@ class CampaignEmailService { return await this.campaignQueue.addBulk( data.map((item) => ({ name: `contact-${item.contact.id}`, - data: item, + data: { + ...item, + teamId: item.emailConfig.teamId, + }, opts: { ...DEFAULT_QUEUE_OPTIONS, }, diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index 3342793..e54ccbf 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -5,6 +5,7 @@ import * as ses from "~/server/aws/ses"; import { db } from "~/server/db"; import { SesSettingsService } from "./ses-settings-service"; import { UnsendApiError } from "../public-api/api-error"; +import { logger } from "../logger/log"; const dnsResolveTxt = util.promisify(dns.resolveTxt); @@ -60,7 +61,7 @@ export async function createDomain( ) { const domainStr = tldts.getDomain(name); - console.log("Creating domain", { domainStr, name, region }); + logger.info({ domainStr, name, region }, "Creating domain"); if (!domainStr) { throw new Error("Invalid domain"); @@ -191,7 +192,7 @@ async function getDmarcRecord(domain: string) { const dmarcRecord = await dnsResolveTxt(`_dmarc.${domain}`); return dmarcRecord; } catch (error) { - console.error("Error fetching DMARC record:", error); + logger.error({ err: error, domain }, "Error fetching DMARC record"); return null; // or handle error as appropriate } } diff --git a/apps/web/src/server/service/email-queue-service.ts b/apps/web/src/server/service/email-queue-service.ts index 40edc34..cc35c95 100644 --- a/apps/web/src/server/service/email-queue-service.ts +++ b/apps/web/src/server/service/email-queue-service.ts @@ -8,6 +8,15 @@ import { sendRawEmail } from "../aws/ses"; import { getRedis } from "../redis"; import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants"; import { Prisma } from "@prisma/client"; +import { logger } from "../logger/log"; +import { createWorkerHandler, TeamJob } from "../queue/bullmq-context"; + +type QueueEmailJob = TeamJob<{ + emailId: string; + timestamp: number; + unsubUrl?: string; + isBulk?: boolean; +}>; function createQueueAndWorker(region: string, quota: number, suffix: string) { const connection = getRedis(); @@ -16,7 +25,8 @@ function createQueueAndWorker(region: string, quota: number, suffix: string) { const queue = new Queue(queueName, { connection }); - const worker = new Worker(queueName, executeEmail, { + // TODO: Add team context to job data when queueing + const worker = new Worker(queueName, createWorkerHandler(executeEmail), { concurrency: quota, connection, }); @@ -26,9 +36,9 @@ function createQueueAndWorker(region: string, quota: number, suffix: string) { export class EmailQueueService { private static initialized = false; - public static transactionalQueue = new Map(); + public static transactionalQueue = new Map>(); private static transactionalWorker = new Map(); - public static marketingQueue = new Map(); + public static marketingQueue = new Map>(); private static marketingWorker = new Map(); public static initializeQueue( @@ -36,7 +46,10 @@ export class EmailQueueService { quota: number, transactionalQuotaPercentage: number ) { - console.log(`[EmailQueueService]: Initializing queue for region ${region}`); + logger.info( + { region }, + `[EmailQueueService]: Initializing queue for region` + ); const transactionalQuota = Math.floor( (quota * transactionalQuotaPercentage) / 100 @@ -44,8 +57,9 @@ export class EmailQueueService { const marketingQuota = quota - transactionalQuota; if (this.transactionalQueue.has(region)) { - console.log( - `[EmailQueueService]: Updating transactional quota for region ${region} to ${transactionalQuota}` + logger.info( + { region, transactionalQuota }, + `[EmailQueueService]: Updating transactional quota for region` ); const transactionalWorker = this.transactionalWorker.get(region); if (transactionalWorker) { @@ -53,8 +67,9 @@ export class EmailQueueService { transactionalQuota !== 0 ? transactionalQuota : 1; } } else { - console.log( - `[EmailQueueService]: Creating transactional queue for region ${region} with quota ${transactionalQuota}` + logger.info( + { region, transactionalQuota }, + `[EmailQueueService]: Creating transactional queue for region` ); const { queue: transactionalQueue, worker: transactionalWorker } = createQueueAndWorker( @@ -67,16 +82,18 @@ export class EmailQueueService { } if (this.marketingQueue.has(region)) { - console.log( - `[EmailQueueService]: Updating marketing quota for region ${region} to ${marketingQuota}` + logger.info( + { region, marketingQuota }, + `[EmailQueueService]: Updating marketing quota for region` ); const marketingWorker = this.marketingWorker.get(region); if (marketingWorker) { marketingWorker.concurrency = marketingQuota !== 0 ? marketingQuota : 1; } } else { - console.log( - `[EmailQueueService]: Creating marketing queue for region ${region} with quota ${marketingQuota}` + logger.info( + { region, marketingQuota }, + `[EmailQueueService]: Creating marketing queue for region` ); const { queue: marketingQueue, worker: marketingWorker } = createQueueAndWorker( @@ -91,6 +108,7 @@ export class EmailQueueService { public static async queueEmail( emailId: string, + teamId: number, region: string, transactional: boolean, unsubUrl?: string, @@ -108,7 +126,7 @@ export class EmailQueueService { } queue.add( emailId, - { emailId, timestamp: Date.now(), unsubUrl, isBulk }, + { emailId, timestamp: Date.now(), unsubUrl, isBulk, teamId }, { jobId: emailId, delay, ...DEFAULT_QUEUE_OPTIONS } ); } @@ -123,6 +141,7 @@ export class EmailQueueService { public static async queueBulk( jobs: { emailId: string; + teamId: number; region: string; transactional: boolean; unsubUrl?: string; @@ -131,7 +150,7 @@ export class EmailQueueService { }[] ): Promise { if (jobs.length === 0) { - console.log("[EmailQueueService]: No jobs provided for bulk queue."); + logger.info("[EmailQueueService]: No jobs provided for bulk queue."); return; } @@ -139,8 +158,9 @@ export class EmailQueueService { await this.init(); } - console.log( - `[EmailQueueService]: Starting bulk queue for ${jobs.length} jobs.` + logger.info( + { count: jobs.length }, + `[EmailQueueService]: Starting bulk queue for jobs.` ); // Group jobs by region and type @@ -176,8 +196,9 @@ export class EmailQueueService { for (const groupKey in groupedJobs) { const group = groupedJobs[groupKey]; if (!group || !group.queue) { - console.error( - `[EmailQueueService]: Queue not found for group ${groupKey} during bulk add. Skipping ${group?.jobDetails?.length ?? 0} jobs.` + logger.error( + { groupKey, count: group?.jobDetails?.length ?? 0 }, + `[EmailQueueService]: Queue not found for group during bulk add. Skipping jobs.` ); // Optionally: handle these skipped jobs (e.g., mark corresponding emails as failed) continue; @@ -192,6 +213,7 @@ export class EmailQueueService { timestamp: job.timestamp ?? Date.now(), unsubUrl: job.unsubUrl, isBulk, + teamId: job.teamId, }, opts: { jobId: job.emailId, // Use emailId as jobId @@ -200,14 +222,15 @@ export class EmailQueueService { }, })); - console.log( - `[EmailQueueService]: Adding ${bulkData.length} jobs to queue ${queue.name}` + logger.info( + { count: bulkData.length, queue: queue.name }, + `[EmailQueueService]: Adding jobs to queue` ); bulkAddPromises.push( queue.addBulk(bulkData).catch((error) => { - console.error( - `[EmailQueueService]: Failed to add bulk jobs to queue ${queue.name}:`, - error + logger.error( + { err: error, queue: queue.name }, + `[EmailQueueService]: Failed to add bulk jobs to queue` ); // Optionally: handle bulk add failure (e.g., mark corresponding emails as failed) }) @@ -215,7 +238,7 @@ export class EmailQueueService { } await Promise.allSettled(bulkAddPromises); - console.log( + logger.info( "[EmailQueueService]: Finished processing bulk queue requests." ); } @@ -278,16 +301,10 @@ export class EmailQueueService { } } -async function executeEmail( - job: Job<{ - emailId: string; - timestamp: number; - unsubUrl?: string; - isBulk?: boolean; - }> -) { - console.log( - `[EmailQueueService]: Executing email job ${job.data.emailId}, time elapsed: ${Date.now() - job.data.timestamp}ms` +async function executeEmail(job: QueueEmailJob) { + logger.info( + { emailId: job.data.emailId, elapsed: Date.now() - job.data.timestamp }, + `[EmailQueueService]: Executing email job` ); const email = await db.email.findUnique({ @@ -301,7 +318,10 @@ async function executeEmail( : null; if (!email) { - console.log(`[EmailQueueService]: Email not found, skipping`); + logger.info( + { emailId: job.data.emailId }, + `[EmailQueueService]: Email not found, skipping` + ); return; } @@ -309,7 +329,7 @@ async function executeEmail( ? JSON.parse(email.attachments) : []; - console.log(`Domain: ${JSON.stringify(domain)}`); + logger.info({ domain }, `Domain`); const configurationSetName = await getConfigurationSetName( domain?.clickTracking ?? false, @@ -321,7 +341,7 @@ async function executeEmail( return; } - console.log(`[EmailQueueService]: Sending email ${email.id}`); + logger.info({ emailId: email.id }, `[EmailQueueService]: Sending email`); const unsubUrl = job.data.unsubUrl; const isBulk = job.data.isBulk; diff --git a/apps/web/src/server/service/email-service.ts b/apps/web/src/server/service/email-service.ts index b8e74ec..3b2ad28 100644 --- a/apps/web/src/server/service/email-service.ts +++ b/apps/web/src/server/service/email-service.ts @@ -4,6 +4,7 @@ import { UnsendApiError } from "~/server/public-api/api-error"; import { EmailQueueService } from "./email-queue-service"; import { validateDomainFromEmail } from "./domain-service"; import { EmailRenderer } from "@unsend/email-editor/src/renderer"; +import { logger } from "../logger/log"; async function checkIfValidEmail(emailId: string) { const email = await db.email.findUnique({ @@ -155,6 +156,7 @@ export async function sendEmail( try { await EmailQueueService.queueEmail( email.id, + teamId, domain.region, true, undefined, @@ -399,15 +401,16 @@ export async function sendBulkEmails( // Prepare queue job queueJobs.push({ emailId: email.id, + teamId, region: domain.region, transactional: true, // Bulk emails are still transactional delay, timestamp: Date.now(), }); } catch (error: any) { - console.error( - `Failed to create email record for recipient ${to}:`, - error + logger.error( + { err: error, to }, + `Failed to create email record for recipient` ); // Continue processing other emails } diff --git a/apps/web/src/server/service/notification-service.ts b/apps/web/src/server/service/notification-service.ts index 05984e1..5193aae 100644 --- a/apps/web/src/server/service/notification-service.ts +++ b/apps/web/src/server/service/notification-service.ts @@ -1,11 +1,12 @@ import { env } from "~/env"; +import { logger } from "../logger/log"; export async function sendToDiscord(message: string) { if (!env.DISCORD_WEBHOOK_URL) { - console.error( + logger.error( "Discord webhook URL is not defined in the environment variables. So printing the message to the console." ); - console.log("Message: ", message); + logger.info({ message }, "Message"); return; } @@ -19,9 +20,12 @@ export async function sendToDiscord(message: string) { }); if (response.ok) { - console.log("Message sent to Discord successfully."); + logger.info("Message sent to Discord successfully."); } else { - console.error("Failed to send message to Discord:", response.statusText); + logger.error( + { statusText: response.statusText }, + "Failed to send message to Discord:" + ); } return; diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts index 2b226e4..aaee8c4 100644 --- a/apps/web/src/server/service/ses-hook-parser.ts +++ b/apps/web/src/server/service/ses-hook-parser.ts @@ -17,12 +17,14 @@ import { DEFAULT_QUEUE_OPTIONS, SES_WEBHOOK_QUEUE, } from "../queue/queue-constants"; +import { getChildLogger, logger, withLogger } from "../logger/log"; +import { randomUUID } from "crypto"; export async function parseSesHook(data: SesEvent) { const mailStatus = getEmailStatus(data); if (!mailStatus) { - console.error("Unknown email status", data); + logger.error({ data }, "Unknown email status"); return false; } @@ -36,8 +38,14 @@ export async function parseSesHook(data: SesEvent) { }, }); + logger.setBindings({ + sesEmailId, + mailId: email?.id, + teamId: email?.teamId, + }); + if (!email) { - console.error("Email not found", data); + logger.error({ data }, "Email not found"); return false; } @@ -286,7 +294,14 @@ export class SesHookParser { private static worker = new Worker( SES_WEBHOOK_QUEUE, async (job) => { - await this.execute(job.data); + return await withLogger( + getChildLogger({ + queueId: job.id ?? randomUUID(), + }), + async () => { + await this.execute(job.data); + } + ); }, { connection: getRedis(), diff --git a/apps/web/src/server/service/ses-settings-service.ts b/apps/web/src/server/service/ses-settings-service.ts index dfd5d67..438f5d3 100644 --- a/apps/web/src/server/service/ses-settings-service.ts +++ b/apps/web/src/server/service/ses-settings-service.ts @@ -6,6 +6,7 @@ import * as ses from "~/server/aws/ses"; import { EventType } from "@aws-sdk/client-sesv2"; import { EmailQueueService } from "./email-queue-service"; import { smallNanoid } from "../nanoid"; +import { logger } from "../logger/log"; const GENERAL_EVENTS: EventType[] = [ "BOUNCE", @@ -121,9 +122,12 @@ export class SesSettingsService { setting.sesEmailRateLimit, setting.transactionalQuota ); - console.log( - EmailQueueService.transactionalQueue, - EmailQueueService.marketingQueue + logger.info( + { + transactionalQueue: EmailQueueService.transactionalQueue, + marketingQueue: EmailQueueService.marketingQueue, + }, + "Email queues initialized" ); await this.invalidateCache(); @@ -132,10 +136,13 @@ export class SesSettingsService { try { await sns.deleteTopic(topicArn, region); } catch (deleteError) { - console.error("Failed to delete SNS topic after error:", deleteError); + logger.error( + { err: deleteError }, + "Failed to delete SNS topic after error" + ); } } - console.error("Failed to create SES setting", error); + logger.error({ err: error }, "Failed to create SES setting"); throw error; } } @@ -160,9 +167,12 @@ export class SesSettingsService { sesEmailRateLimit: sendingRateLimit, }, }); - console.log( - EmailQueueService.transactionalQueue, - EmailQueueService.marketingQueue + logger.info( + { + transactionalQueue: EmailQueueService.transactionalQueue, + marketingQueue: EmailQueueService.marketingQueue, + }, + "Email queues before update" ); EmailQueueService.initializeQueue( @@ -171,9 +181,12 @@ export class SesSettingsService { setting.transactionalQuota ); - console.log( - EmailQueueService.transactionalQueue, - EmailQueueService.marketingQueue + logger.info( + { + transactionalQueue: EmailQueueService.transactionalQueue, + marketingQueue: EmailQueueService.marketingQueue, + }, + "Email queues after update" ); await this.invalidateCache(); @@ -261,7 +274,7 @@ async function registerConfigurationSet(setting: SesSetting) { } async function isValidUnsendUrl(url: string) { - console.log("Checking if URL is valid", url); + logger.info({ url }, "Checking if URL is valid"); try { const response = await fetch(`${url}/api/ses_callback`, { method: "GET", @@ -272,7 +285,7 @@ async function isValidUnsendUrl(url: string) { error: response.statusText, }; } catch (e) { - console.log("Error checking if URL is valid", e); + logger.error({ err: e }, "Error checking if URL is valid"); return { isValid: false, code: 500, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5600c2..fbefb4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -224,6 +224,12 @@ importers: nodemailer: specifier: ^7.0.3 version: 7.0.3 + pino: + specifier: ^9.7.0 + version: 9.7.0 + pino-pretty: + specifier: ^13.0.0 + version: 13.0.0 pnpm: specifier: ^10.9.0 version: 10.9.0 @@ -8054,6 +8060,11 @@ packages: /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + /atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + dev: false + /autoprefixer@10.4.21(postcss@8.5.3): resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} engines: {node: ^10 || ^12 || >=14} @@ -8608,6 +8619,10 @@ packages: resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} dev: true + /colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + dev: false + /combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -8875,6 +8890,10 @@ packages: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} dev: false + /dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dev: false + /dayjs@1.11.13: resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} dev: false @@ -9253,7 +9272,6 @@ packages: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} dependencies: once: 1.4.0 - dev: true /engine.io-parser@5.2.3: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} @@ -10413,6 +10431,10 @@ packages: - supports-color dev: true + /fast-copy@3.0.2: + resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} + dev: false + /fast-deep-equal@2.0.1: resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==} dev: false @@ -10460,6 +10482,15 @@ packages: resolution: {integrity: sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==} dev: true + /fast-redact@3.5.0: + resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} + engines: {node: '>=6'} + dev: false + + /fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + dev: false + /fast-uri@3.0.6: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} dev: true @@ -11292,6 +11323,10 @@ packages: hasBin: true dev: false + /help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + dev: false + /highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} dev: false @@ -11903,7 +11938,6 @@ packages: /joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} - dev: true /js-beautify@1.15.4: resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} @@ -13075,7 +13109,6 @@ packages: /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: true /minipass@3.3.6: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} @@ -13532,6 +13565,11 @@ packages: engines: {node: ^10.13.0 || >=12.0.0} dev: false + /on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + dev: false + /on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -13543,7 +13581,6 @@ packages: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: wrappy: 1.0.2 - dev: true /onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} @@ -13904,6 +13941,52 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} + /pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + dependencies: + split2: 4.2.0 + dev: false + + /pino-pretty@13.0.0: + resolution: {integrity: sha512-cQBBIVG3YajgoUjo1FdKVRX6t9XPxwB9lcNJVD5GCnNM4Y6T12YYx8c6zEejxQsU0wrg9TwmDulcE9LR7qcJqA==} + hasBin: true + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 3.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pump: 3.0.2 + secure-json-parse: 2.7.0 + sonic-boom: 4.2.0 + strip-json-comments: 3.1.1 + dev: false + + /pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + dev: false + + /pino@9.7.0: + resolution: {integrity: sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg==} + hasBin: true + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + dev: false + /pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -14187,6 +14270,10 @@ packages: engines: {node: '>=6'} dev: false + /process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + dev: false + /progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -14401,7 +14488,6 @@ packages: dependencies: end-of-stream: 1.4.4 once: 1.4.0 - dev: true /punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} @@ -14472,6 +14558,10 @@ packages: /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + /quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + dev: false + /quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -14744,6 +14834,11 @@ packages: engines: {node: '>= 14.18.0'} dev: true + /real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + dev: false + /recharts-scale@0.4.5: resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} dependencies: @@ -15397,6 +15492,11 @@ packages: resolution: {integrity: sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw==} dev: true + /safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + dev: false + /safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -15422,6 +15522,10 @@ packages: kind-of: 6.0.3 dev: true + /secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + dev: false + /selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} dependencies: @@ -15762,6 +15866,12 @@ packages: smart-buffer: 4.2.0 dev: true + /sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + dependencies: + atomic-sleep: 1.0.0 + dev: false + /sonner@2.0.3(react-dom@19.1.0)(react@19.1.0): resolution: {integrity: sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA==} peerDependencies: @@ -15851,6 +15961,11 @@ packages: engines: {node: '>=12'} dev: false + /split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + dev: false + /sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} dev: true @@ -16202,6 +16317,12 @@ packages: dependencies: any-promise: 1.3.0 + /thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + dependencies: + real-require: 0.2.0 + dev: false + /through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} dev: true @@ -17058,7 +17179,6 @@ packages: /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - dev: true /ws@8.17.1: resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==}