add logging (#187)

This commit is contained in:
KM Koushik
2025-07-26 20:05:34 +10:00
committed by GitHub
parent 5612d7a3eb
commit 202fbeacb6
24 changed files with 490 additions and 134 deletions

28
.github/workflows/opencode.yml vendored Normal file
View File

@@ -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

View File

@@ -49,6 +49,8 @@
"next": "^15.3.1", "next": "^15.3.1",
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"nodemailer": "^7.0.3", "nodemailer": "^7.0.3",
"pino": "^9.7.0",
"pino-pretty": "^13.0.0",
"pnpm": "^10.9.0", "pnpm": "^10.9.0",
"prisma": "^6.6.0", "prisma": "^6.6.0",
"query-string": "^9.1.1", "query-string": "^9.1.1",

View File

@@ -1,5 +1,6 @@
import { env } from "~/env"; import { env } from "~/env";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { logger } from "~/server/logger/log";
import { parseSesHook, SesHookParser } from "~/server/service/ses-hook-parser"; import { parseSesHook, SesHookParser } from "~/server/service/ses-hook-parser";
import { SesSettingsService } from "~/server/service/ses-settings-service"; import { SesSettingsService } from "~/server/service/ses-settings-service";
import { SnsNotificationMessage } from "~/types/aws-types"; import { SnsNotificationMessage } from "~/types/aws-types";

View File

@@ -9,6 +9,7 @@ import {
campaignProcedure, campaignProcedure,
publicProcedure, publicProcedure,
} from "~/server/api/trpc"; } from "~/server/api/trpc";
import { logger } from "~/server/logger/log";
import { nanoid } from "~/server/nanoid"; import { nanoid } from "~/server/nanoid";
import { import {
sendCampaign, sendCampaign,
@@ -66,14 +67,15 @@ export const campaignRouter = createTRPCRouter({
let time = performance.now(); let time = performance.now();
campaignsP.then((campaigns) => { campaignsP.then((campaigns) => {
console.log( logger.info(
`Time taken to get campaigns: ${performance.now() - time} milliseconds` `Time taken to get campaigns: ${performance.now() - time} milliseconds`
); );
}); });
const [campaigns, count] = await Promise.all([campaignsP, countP]); const [campaigns, count] = await Promise.all([campaignsP, countP]);
console.log( logger.info(
`Time taken to complete request: ${performance.now() - completeTime} milliseconds` { duration: performance.now() - completeTime },
`Time taken to complete request`
); );
return { campaigns, totalPage: Math.ceil(count / limit) }; return { campaigns, totalPage: Math.ceil(count / limit) };

View File

@@ -10,6 +10,7 @@ import {
} from "~/server/api/trpc"; } from "~/server/api/trpc";
import { sendTeamInviteEmail } from "~/server/mailer"; import { sendTeamInviteEmail } from "~/server/mailer";
import send from "~/server/public-api/api/emails/send-email"; import send from "~/server/public-api/api/emails/send-email";
import { logger } from "~/server/logger/log";
export const teamRouter = createTRPCRouter({ export const teamRouter = createTRPCRouter({
createTeam: protectedProcedure createTeam: protectedProcedure
@@ -26,7 +27,7 @@ export const teamRouter = createTRPCRouter({
}); });
if (teams.length > 0) { if (teams.length > 0) {
console.log("User already has a team"); logger.info({ userId: ctx.session.user.id }, "User already has a team");
return; return;
} }

View File

@@ -14,6 +14,8 @@ import { env } from "~/env";
import { getServerAuthSession } from "~/server/auth"; import { getServerAuthSession } from "~/server/auth";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { getChildLogger, logger, withLogger } from "../logger/log";
import { randomUUID } from "crypto";
/** /**
* 1. CONTEXT * 1. CONTEXT
@@ -118,13 +120,22 @@ export const teamProcedure = protectedProcedure.use(async ({ ctx, next }) => {
if (!teamUser) { if (!teamUser) {
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
} }
return next({
ctx: { return withLogger(
team: teamUser.team, getChildLogger({
teamUser, teamId: teamUser.team.id,
session: { ...ctx.session, user: ctx.session.user }, 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 }) => { export const teamAdminProcedure = teamProcedure.use(async ({ ctx, next }) => {

View File

@@ -17,6 +17,7 @@ import { Readable } from "stream";
import { env } from "~/env"; import { env } from "~/env";
import { EmailContent } from "~/types"; import { EmailContent } from "~/types";
import { nanoid } from "../nanoid"; import { nanoid } from "../nanoid";
import { logger } from "../logger/log";
function getSesClient(region: string) { function getSesClient(region: string) {
return new SESv2Client({ return new SESv2Client({
@@ -79,8 +80,10 @@ export async function addDomain(domain: string, region: string) {
response.$metadata.httpStatusCode !== 200 || response.$metadata.httpStatusCode !== 200 ||
emailIdentityResponse.$metadata.httpStatusCode !== 200 emailIdentityResponse.$metadata.httpStatusCode !== 200
) { ) {
console.log(response); logger.error(
console.log(emailIdentityResponse); { response, emailIdentityResponse },
"Failed to create domain identity"
);
throw new Error("Failed to create domain identity"); throw new Error("Failed to create domain identity");
} }
@@ -185,10 +188,10 @@ export async function sendRawEmail({
try { try {
const response = await sesClient.send(command); const response = await sesClient.send(command);
console.log("Email sent! Message ID:", response.MessageId); logger.info({ messageId: response.MessageId }, "Email sent!");
return response.MessageId; return response.MessageId;
} catch (error) { } 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 // It's better to throw the original error or a new error with more context
// throw new Error("Failed to send email"); // throw new Error("Failed to send email");
throw error; throw error;

View File

@@ -1,8 +1,9 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { env } from "~/env"; import { env } from "~/env";
import { logger } from "./logger/log";
const createPrismaClient = () => { const createPrismaClient = () => {
console.log("Creating Prisma client"); logger.info("Creating Prisma client");
const client = new PrismaClient({ const client = new PrismaClient({
log: log:
env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],

View File

@@ -5,6 +5,7 @@ import { getUsageDate, getUsageUinits } from "~/lib/usage";
import { sendUsageToStripe } from "~/server/billing/usage"; import { sendUsageToStripe } from "~/server/billing/usage";
import { getRedis } from "~/server/redis"; import { getRedis } from "~/server/redis";
import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants"; import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants";
import { logger } from "../logger/log";
const USAGE_QUEUE_NAME = "usage-reporting"; const USAGE_QUEUE_NAME = "usage-reporting";
@@ -12,7 +13,6 @@ const usageQueue = new Queue(USAGE_QUEUE_NAME, {
connection: getRedis(), connection: getRedis(),
}); });
// Process usage reporting jobs
const worker = new Worker( const worker = new Worker(
USAGE_QUEUE_NAME, USAGE_QUEUE_NAME,
async () => { async () => {
@@ -51,13 +51,18 @@ const worker = new Worker(
try { try {
await sendUsageToStripe(team.stripeCustomerId, totalUsage); await sendUsageToStripe(team.stripeCustomerId, totalUsage);
console.log( logger.info(
`[Usage Reporting] Reported usage for team ${team.id}, date: ${getUsageDate()}, usage: ${totalUsage}` { teamId: team.id, date: getUsageDate(), usage: totalUsage },
`[Usage Reporting] Reported usage for team`
); );
} catch (error) { } catch (error) {
console.error( logger.error(
`[Usage Reporting] Failed to report usage for team ${team.id}:`, {
error instanceof Error ? error.message : 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) => { 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) => { worker.on("failed", (job, err) => {
console.error(`[Usage Reporting] Job ${job?.id} failed:`, err); logger.error({ err, jobId: job?.id }, `[Usage Reporting] Job failed`);
}); });

View File

@@ -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<Store>();
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<string, any>) => void },
{
get(target, prop, receiver) {
// Handle the special setBindings method
if (prop === "setBindings") {
return (bindings: Record<string, any>) => {
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<T>(child: pino.Logger, fn: () => Promise<T> | T) {
return loggerStore.run({ logger: child }, fn);
}
export function getChildLogger({
teamId,
requestId,
...rest
}: {
teamId?: number;
requestId?: string;
} & Record<string, any>) {
return logger.child({ teamId, requestId, ...rest });
}

View File

@@ -4,6 +4,7 @@ import { isSelfHosted } from "~/utils/common";
import { db } from "./db"; import { db } from "./db";
import { getDomains } from "./service/domain-service"; import { getDomains } from "./service/domain-service";
import { sendEmail } from "./service/email-service"; import { sendEmail } from "./service/email-service";
import { logger } from "./logger/log";
let unsend: Unsend | undefined; let unsend: Unsend | undefined;
@@ -22,7 +23,7 @@ export async function sendSignUpEmail(
const { host } = new URL(url); const { host } = new URL(url);
if (env.NODE_ENV === "development") { if (env.NODE_ENV === "development") {
console.log("Sending sign in email", { email, url, token }); logger.info({ email, url, token }, "Sending sign in email");
return; return;
} }
@@ -41,7 +42,7 @@ export async function sendTeamInviteEmail(
const { host } = new URL(url); const { host } = new URL(url);
if (env.NODE_ENV === "development") { if (env.NODE_ENV === "development") {
console.log("Sending team invite email", { email, url, teamName }); logger.info({ email, url, teamName }, "Sending team invite email");
return; return;
} }
@@ -59,7 +60,7 @@ async function sendMail(
html: string html: string
) { ) {
if (isSelfHosted()) { 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 Self hosted so checking if we can send using one of the available domain
Assuming self hosted will have only one team Assuming self hosted will have only one team
@@ -67,14 +68,14 @@ async function sendMail(
*/ */
const team = await db.team.findFirst({}); const team = await db.team.findFirst({});
if (!team) { if (!team) {
console.error("No team found"); logger.error("No team found");
return; return;
} }
const domains = await getDomains(team.id); const domains = await getDomains(team.id);
if (domains.length === 0 || !domains[0]) { if (domains.length === 0 || !domains[0]) {
console.error("No domains found"); logger.error("No domains found");
return; return;
} }
@@ -101,13 +102,12 @@ async function sendMail(
}); });
if (resp.data) { if (resp.data) {
console.log("Email sent using unsend"); logger.info("Email sent using unsend");
return; return;
} else { } else {
console.log( logger.error(
"Error sending email using unsend, so fallback to resend", { code: resp.error?.code, message: resp.error?.message },
resp.error?.code, "Error sending email using unsend, so fallback to resend"
resp.error?.message
); );
} }
} else { } else {

View File

@@ -2,6 +2,7 @@ import { Context } from "hono";
import { HTTPException } from "hono/http-exception"; import { HTTPException } from "hono/http-exception";
import { StatusCode, ContentfulStatusCode } from "hono/utils/http-status"; import { StatusCode, ContentfulStatusCode } from "hono/utils/http-status";
import { z } from "zod"; import { z } from "zod";
import { logger } from "../logger/log";
const ErrorCode = z.enum([ const ErrorCode = z.enum([
"BAD_REQUEST", "BAD_REQUEST",
@@ -79,11 +80,10 @@ export function handleError(err: Error, c: Context): Response {
*/ */
if (err instanceof UnsendApiError) { if (err instanceof UnsendApiError) {
if (err.status >= 500) { if (err.status >= 500) {
console.error(err.message, { logger.error(
name: err.name, { name: err.name, code: err.code, status: err.status, err },
code: err.code, err.message
status: err.status, );
});
} }
return c.json( return c.json(
{ {
@@ -102,10 +102,10 @@ export function handleError(err: Error, c: Context): Response {
*/ */
if (err instanceof HTTPException) { if (err instanceof HTTPException) {
if (err.status >= 500) { if (err.status >= 500) {
console.error("HTTPException", { logger.error(
message: err.message, { message: err.message, status: err.status, err },
status: err.status, "HTTPException"
}); );
} }
const code = statusToCode(err.status); const code = statusToCode(err.status);
return c.json( 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 * We're lost here, all we can do is return a 500 and log it to investigate
*/ */
console.error("unhandled exception", { logger.error(
name: err.name, {
message: err.message, err,
cause: err.cause, name: err.name,
stack: err.stack, message: err.message,
}); cause: err.cause,
stack: err.stack,
},
"unhandled exception"
);
return c.json( return c.json(
{ {
error: { error: {

View File

@@ -3,6 +3,7 @@ import { db } from "../db";
import { UnsendApiError } from "./api-error"; import { UnsendApiError } from "./api-error";
import { getTeamAndApiKey } from "../service/api-service"; import { getTeamAndApiKey } from "../service/api-service";
import { isSelfHosted } from "~/utils/common"; import { isSelfHosted } from "~/utils/common";
import { logger } from "../logger/log";
/** /**
* Gets the team from the token. Also will check if the token is valid. * 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(), lastUsed: new Date(),
}, },
}) })
.catch(console.error); .catch((err) =>
logger.error({ err }, "Failed to update lastUsed on API key")
);
return { ...team, apiKeyId: apiKey.id }; return { ...team, apiKeyId: apiKey.id };
}; };

View File

@@ -8,6 +8,7 @@ import { getTeamFromToken } from "~/server/public-api/auth";
import { isSelfHosted } from "~/utils/common"; import { isSelfHosted } from "~/utils/common";
import { UnsendApiError } from "./api-error"; import { UnsendApiError } from "./api-error";
import { Team } from "@prisma/client"; import { Team } from "@prisma/client";
import { logger } from "../logger/log";
// Define AppEnv for Hono context // Define AppEnv for Hono context
export type AppEnv = { export type AppEnv = {
@@ -38,7 +39,7 @@ export function getApp() {
if (error instanceof UnsendApiError) { if (error instanceof UnsendApiError) {
throw error; throw error;
} }
console.error("Error in getTeamFromToken middleware:", error); logger.error({ err: error }, "Error in getTeamFromToken middleware");
throw new UnsendApiError({ throw new UnsendApiError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Authentication failed", message: "Authentication failed",
@@ -84,7 +85,7 @@ export function getApp() {
// We rely on expire being set for new keys. // We rely on expire being set for new keys.
ttl = await redis.ttl(key); ttl = await redis.ttl(key);
} catch (error) { } 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. // Alternatively, you could fail closed by throwing an error here.
return next(); return next();
} }

View File

@@ -0,0 +1,24 @@
import { randomUUID } from "crypto";
import { getChildLogger, withLogger } from "../logger/log";
import { Job } from "bullmq";
export type TeamJob<T> = Job<T & { teamId?: number }>;
/**
* Simple wrapper function for BullMQ worker jobs with team context
*/
export function createWorkerHandler<T>(
handler: (job: TeamJob<T>) => Promise<void>
) {
return async (job: TeamJob<T>) => {
return await withLogger(
getChildLogger({
teamId: job.data.teamId,
queueId: job.id ?? randomUUID(),
}),
async () => {
return await handler(job);
}
);
};
}

View File

@@ -3,6 +3,7 @@ import { db } from "../db";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { smallNanoid } from "../nanoid"; import { smallNanoid } from "../nanoid";
import { createSecureHash, verifySecureHash } from "../crypto"; import { createSecureHash, verifySecureHash } from "../crypto";
import { logger } from "../logger/log";
export async function addApiKey({ export async function addApiKey({
name, name,
@@ -32,7 +33,7 @@ export async function addApiKey({
}); });
return apiKey; return apiKey;
} catch (error) { } catch (error) {
console.error("Error adding API key:", error); logger.error({ err: error }, "Error adding API key");
throw error; throw error;
} }
} }
@@ -64,7 +65,7 @@ export async function getTeamAndApiKey(apiKey: string) {
return { team, apiKey: apiKeyRow }; return { team, apiKey: apiKeyRow };
} catch (error) { } catch (error) {
console.error("Error verifying API key:", error); logger.error({ err: error }, "Error verifying API key");
return null; return null;
} }
} }
@@ -77,7 +78,7 @@ export async function deleteApiKey(id: number) {
}, },
}); });
} catch (error) { } catch (error) {
console.error("Error deleting API key:", error); logger.error({ err: error }, "Error deleting API key");
throw error; throw error;
} }
} }

View File

@@ -16,6 +16,8 @@ import {
CAMPAIGN_MAIL_PROCESSING_QUEUE, CAMPAIGN_MAIL_PROCESSING_QUEUE,
DEFAULT_QUEUE_OPTIONS, DEFAULT_QUEUE_OPTIONS,
} from "../queue/queue-constants"; } from "../queue/queue-constants";
import { logger } from "../logger/log";
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
export async function sendCampaign(id: string) { export async function sendCampaign(id: string) {
let campaign = await db.campaign.findUnique({ let campaign = await db.campaign.findUnique({
@@ -41,7 +43,7 @@ export async function sendCampaign(id: string) {
data: { html }, data: { html },
}); });
} catch (error) { } catch (error) {
console.error(error); logger.error({ err: error }, "Failed to parse campaign content");
throw new Error("Failed to parse campaign content"); throw new Error("Failed to parse campaign content");
} }
@@ -158,7 +160,7 @@ export async function unsubscribeContact({
return contact; return contact;
} catch (error) { } catch (error) {
console.error("Error unsubscribing contact:", error); logger.error({ err: error }, "Error unsubscribing contact");
throw new Error("Failed to unsubscribe contact"); throw new Error("Failed to unsubscribe contact");
} }
} }
@@ -207,7 +209,7 @@ export async function subscribeContact(id: string, hash: string) {
return true; return true;
} catch (error) { } catch (error) {
console.error("Error subscribing contact:", error); logger.error({ err: error }, "Error subscribing contact");
throw new Error("Failed to subscribe contact"); throw new Error("Failed to subscribe contact");
} }
} }
@@ -242,6 +244,8 @@ type CampaignEmailJob = {
}; };
}; };
type QueueCampaignEmailJob = TeamJob<CampaignEmailJob>;
async function processContactEmail(jobData: CampaignEmailJob) { async function processContactEmail(jobData: CampaignEmailJob) {
const { contact, campaign, emailConfig } = jobData; const { contact, campaign, emailConfig } = jobData;
const jsonContent = JSON.parse(campaign.content || "{}"); const jsonContent = JSON.parse(campaign.content || "{}");
@@ -282,6 +286,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
// Queue email for sending // Queue email for sending
await EmailQueueService.queueEmail( await EmailQueueService.queueEmail(
email.id, email.id,
emailConfig.teamId,
emailConfig.region, emailConfig.region,
false, false,
unsubscribeUrl unsubscribeUrl
@@ -306,7 +311,7 @@ export async function sendCampaignEmail(
const domain = await validateDomainFromEmail(from, teamId); const domain = await validateDomainFromEmail(from, teamId);
console.log("Bulk queueing contacts"); logger.info("Bulk queueing contacts");
await CampaignEmailService.queueBulkContacts( await CampaignEmailService.queueBulkContacts(
contacts.map((contact) => ({ contacts.map((contact) => ({
@@ -382,15 +387,19 @@ export async function updateCampaignAnalytics(
const CAMPAIGN_EMAIL_CONCURRENCY = 50; const CAMPAIGN_EMAIL_CONCURRENCY = 50;
class CampaignEmailService { class CampaignEmailService {
private static campaignQueue = new Queue(CAMPAIGN_MAIL_PROCESSING_QUEUE, { private static campaignQueue = new Queue<QueueCampaignEmailJob>(
connection: getRedis(), CAMPAIGN_MAIL_PROCESSING_QUEUE,
}); {
connection: getRedis(),
}
);
// TODO: Add team context to job data when queueing
static worker = new Worker( static worker = new Worker(
CAMPAIGN_MAIL_PROCESSING_QUEUE, CAMPAIGN_MAIL_PROCESSING_QUEUE,
async (job) => { createWorkerHandler(async (job: QueueCampaignEmailJob) => {
await processContactEmail(job.data); await processContactEmail(job.data);
}, }),
{ {
connection: getRedis(), connection: getRedis(),
concurrency: CAMPAIGN_EMAIL_CONCURRENCY, concurrency: CAMPAIGN_EMAIL_CONCURRENCY,
@@ -400,7 +409,10 @@ class CampaignEmailService {
static async queueContact(data: CampaignEmailJob) { static async queueContact(data: CampaignEmailJob) {
return await this.campaignQueue.add( return await this.campaignQueue.add(
`contact-${data.contact.id}`, `contact-${data.contact.id}`,
data, {
...data,
teamId: data.emailConfig.teamId,
},
DEFAULT_QUEUE_OPTIONS DEFAULT_QUEUE_OPTIONS
); );
} }
@@ -409,7 +421,10 @@ class CampaignEmailService {
return await this.campaignQueue.addBulk( return await this.campaignQueue.addBulk(
data.map((item) => ({ data.map((item) => ({
name: `contact-${item.contact.id}`, name: `contact-${item.contact.id}`,
data: item, data: {
...item,
teamId: item.emailConfig.teamId,
},
opts: { opts: {
...DEFAULT_QUEUE_OPTIONS, ...DEFAULT_QUEUE_OPTIONS,
}, },

View File

@@ -5,6 +5,7 @@ import * as ses from "~/server/aws/ses";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { SesSettingsService } from "./ses-settings-service"; import { SesSettingsService } from "./ses-settings-service";
import { UnsendApiError } from "../public-api/api-error"; import { UnsendApiError } from "../public-api/api-error";
import { logger } from "../logger/log";
const dnsResolveTxt = util.promisify(dns.resolveTxt); const dnsResolveTxt = util.promisify(dns.resolveTxt);
@@ -60,7 +61,7 @@ export async function createDomain(
) { ) {
const domainStr = tldts.getDomain(name); const domainStr = tldts.getDomain(name);
console.log("Creating domain", { domainStr, name, region }); logger.info({ domainStr, name, region }, "Creating domain");
if (!domainStr) { if (!domainStr) {
throw new Error("Invalid domain"); throw new Error("Invalid domain");
@@ -191,7 +192,7 @@ async function getDmarcRecord(domain: string) {
const dmarcRecord = await dnsResolveTxt(`_dmarc.${domain}`); const dmarcRecord = await dnsResolveTxt(`_dmarc.${domain}`);
return dmarcRecord; return dmarcRecord;
} catch (error) { } 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 return null; // or handle error as appropriate
} }
} }

View File

@@ -8,6 +8,15 @@ import { sendRawEmail } from "../aws/ses";
import { getRedis } from "../redis"; import { getRedis } from "../redis";
import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants"; import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants";
import { Prisma } from "@prisma/client"; 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) { function createQueueAndWorker(region: string, quota: number, suffix: string) {
const connection = getRedis(); const connection = getRedis();
@@ -16,7 +25,8 @@ function createQueueAndWorker(region: string, quota: number, suffix: string) {
const queue = new Queue(queueName, { connection }); 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, concurrency: quota,
connection, connection,
}); });
@@ -26,9 +36,9 @@ function createQueueAndWorker(region: string, quota: number, suffix: string) {
export class EmailQueueService { export class EmailQueueService {
private static initialized = false; private static initialized = false;
public static transactionalQueue = new Map<string, Queue>(); public static transactionalQueue = new Map<string, Queue<QueueEmailJob>>();
private static transactionalWorker = new Map<string, Worker>(); private static transactionalWorker = new Map<string, Worker>();
public static marketingQueue = new Map<string, Queue>(); public static marketingQueue = new Map<string, Queue<QueueEmailJob>>();
private static marketingWorker = new Map<string, Worker>(); private static marketingWorker = new Map<string, Worker>();
public static initializeQueue( public static initializeQueue(
@@ -36,7 +46,10 @@ export class EmailQueueService {
quota: number, quota: number,
transactionalQuotaPercentage: number transactionalQuotaPercentage: number
) { ) {
console.log(`[EmailQueueService]: Initializing queue for region ${region}`); logger.info(
{ region },
`[EmailQueueService]: Initializing queue for region`
);
const transactionalQuota = Math.floor( const transactionalQuota = Math.floor(
(quota * transactionalQuotaPercentage) / 100 (quota * transactionalQuotaPercentage) / 100
@@ -44,8 +57,9 @@ export class EmailQueueService {
const marketingQuota = quota - transactionalQuota; const marketingQuota = quota - transactionalQuota;
if (this.transactionalQueue.has(region)) { if (this.transactionalQueue.has(region)) {
console.log( logger.info(
`[EmailQueueService]: Updating transactional quota for region ${region} to ${transactionalQuota}` { region, transactionalQuota },
`[EmailQueueService]: Updating transactional quota for region`
); );
const transactionalWorker = this.transactionalWorker.get(region); const transactionalWorker = this.transactionalWorker.get(region);
if (transactionalWorker) { if (transactionalWorker) {
@@ -53,8 +67,9 @@ export class EmailQueueService {
transactionalQuota !== 0 ? transactionalQuota : 1; transactionalQuota !== 0 ? transactionalQuota : 1;
} }
} else { } else {
console.log( logger.info(
`[EmailQueueService]: Creating transactional queue for region ${region} with quota ${transactionalQuota}` { region, transactionalQuota },
`[EmailQueueService]: Creating transactional queue for region`
); );
const { queue: transactionalQueue, worker: transactionalWorker } = const { queue: transactionalQueue, worker: transactionalWorker } =
createQueueAndWorker( createQueueAndWorker(
@@ -67,16 +82,18 @@ export class EmailQueueService {
} }
if (this.marketingQueue.has(region)) { if (this.marketingQueue.has(region)) {
console.log( logger.info(
`[EmailQueueService]: Updating marketing quota for region ${region} to ${marketingQuota}` { region, marketingQuota },
`[EmailQueueService]: Updating marketing quota for region`
); );
const marketingWorker = this.marketingWorker.get(region); const marketingWorker = this.marketingWorker.get(region);
if (marketingWorker) { if (marketingWorker) {
marketingWorker.concurrency = marketingQuota !== 0 ? marketingQuota : 1; marketingWorker.concurrency = marketingQuota !== 0 ? marketingQuota : 1;
} }
} else { } else {
console.log( logger.info(
`[EmailQueueService]: Creating marketing queue for region ${region} with quota ${marketingQuota}` { region, marketingQuota },
`[EmailQueueService]: Creating marketing queue for region`
); );
const { queue: marketingQueue, worker: marketingWorker } = const { queue: marketingQueue, worker: marketingWorker } =
createQueueAndWorker( createQueueAndWorker(
@@ -91,6 +108,7 @@ export class EmailQueueService {
public static async queueEmail( public static async queueEmail(
emailId: string, emailId: string,
teamId: number,
region: string, region: string,
transactional: boolean, transactional: boolean,
unsubUrl?: string, unsubUrl?: string,
@@ -108,7 +126,7 @@ export class EmailQueueService {
} }
queue.add( queue.add(
emailId, emailId,
{ emailId, timestamp: Date.now(), unsubUrl, isBulk }, { emailId, timestamp: Date.now(), unsubUrl, isBulk, teamId },
{ jobId: emailId, delay, ...DEFAULT_QUEUE_OPTIONS } { jobId: emailId, delay, ...DEFAULT_QUEUE_OPTIONS }
); );
} }
@@ -123,6 +141,7 @@ export class EmailQueueService {
public static async queueBulk( public static async queueBulk(
jobs: { jobs: {
emailId: string; emailId: string;
teamId: number;
region: string; region: string;
transactional: boolean; transactional: boolean;
unsubUrl?: string; unsubUrl?: string;
@@ -131,7 +150,7 @@ export class EmailQueueService {
}[] }[]
): Promise<void> { ): Promise<void> {
if (jobs.length === 0) { if (jobs.length === 0) {
console.log("[EmailQueueService]: No jobs provided for bulk queue."); logger.info("[EmailQueueService]: No jobs provided for bulk queue.");
return; return;
} }
@@ -139,8 +158,9 @@ export class EmailQueueService {
await this.init(); await this.init();
} }
console.log( logger.info(
`[EmailQueueService]: Starting bulk queue for ${jobs.length} jobs.` { count: jobs.length },
`[EmailQueueService]: Starting bulk queue for jobs.`
); );
// Group jobs by region and type // Group jobs by region and type
@@ -176,8 +196,9 @@ export class EmailQueueService {
for (const groupKey in groupedJobs) { for (const groupKey in groupedJobs) {
const group = groupedJobs[groupKey]; const group = groupedJobs[groupKey];
if (!group || !group.queue) { if (!group || !group.queue) {
console.error( logger.error(
`[EmailQueueService]: Queue not found for group ${groupKey} during bulk add. Skipping ${group?.jobDetails?.length ?? 0} jobs.` { 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) // Optionally: handle these skipped jobs (e.g., mark corresponding emails as failed)
continue; continue;
@@ -192,6 +213,7 @@ export class EmailQueueService {
timestamp: job.timestamp ?? Date.now(), timestamp: job.timestamp ?? Date.now(),
unsubUrl: job.unsubUrl, unsubUrl: job.unsubUrl,
isBulk, isBulk,
teamId: job.teamId,
}, },
opts: { opts: {
jobId: job.emailId, // Use emailId as jobId jobId: job.emailId, // Use emailId as jobId
@@ -200,14 +222,15 @@ export class EmailQueueService {
}, },
})); }));
console.log( logger.info(
`[EmailQueueService]: Adding ${bulkData.length} jobs to queue ${queue.name}` { count: bulkData.length, queue: queue.name },
`[EmailQueueService]: Adding jobs to queue`
); );
bulkAddPromises.push( bulkAddPromises.push(
queue.addBulk(bulkData).catch((error) => { queue.addBulk(bulkData).catch((error) => {
console.error( logger.error(
`[EmailQueueService]: Failed to add bulk jobs to queue ${queue.name}:`, { err: error, queue: queue.name },
error `[EmailQueueService]: Failed to add bulk jobs to queue`
); );
// Optionally: handle bulk add failure (e.g., mark corresponding emails as failed) // Optionally: handle bulk add failure (e.g., mark corresponding emails as failed)
}) })
@@ -215,7 +238,7 @@ export class EmailQueueService {
} }
await Promise.allSettled(bulkAddPromises); await Promise.allSettled(bulkAddPromises);
console.log( logger.info(
"[EmailQueueService]: Finished processing bulk queue requests." "[EmailQueueService]: Finished processing bulk queue requests."
); );
} }
@@ -278,16 +301,10 @@ export class EmailQueueService {
} }
} }
async function executeEmail( async function executeEmail(job: QueueEmailJob) {
job: Job<{ logger.info(
emailId: string; { emailId: job.data.emailId, elapsed: Date.now() - job.data.timestamp },
timestamp: number; `[EmailQueueService]: Executing email job`
unsubUrl?: string;
isBulk?: boolean;
}>
) {
console.log(
`[EmailQueueService]: Executing email job ${job.data.emailId}, time elapsed: ${Date.now() - job.data.timestamp}ms`
); );
const email = await db.email.findUnique({ const email = await db.email.findUnique({
@@ -301,7 +318,10 @@ async function executeEmail(
: null; : null;
if (!email) { if (!email) {
console.log(`[EmailQueueService]: Email not found, skipping`); logger.info(
{ emailId: job.data.emailId },
`[EmailQueueService]: Email not found, skipping`
);
return; return;
} }
@@ -309,7 +329,7 @@ async function executeEmail(
? JSON.parse(email.attachments) ? JSON.parse(email.attachments)
: []; : [];
console.log(`Domain: ${JSON.stringify(domain)}`); logger.info({ domain }, `Domain`);
const configurationSetName = await getConfigurationSetName( const configurationSetName = await getConfigurationSetName(
domain?.clickTracking ?? false, domain?.clickTracking ?? false,
@@ -321,7 +341,7 @@ async function executeEmail(
return; return;
} }
console.log(`[EmailQueueService]: Sending email ${email.id}`); logger.info({ emailId: email.id }, `[EmailQueueService]: Sending email`);
const unsubUrl = job.data.unsubUrl; const unsubUrl = job.data.unsubUrl;
const isBulk = job.data.isBulk; const isBulk = job.data.isBulk;

View File

@@ -4,6 +4,7 @@ import { UnsendApiError } from "~/server/public-api/api-error";
import { EmailQueueService } from "./email-queue-service"; import { EmailQueueService } from "./email-queue-service";
import { validateDomainFromEmail } from "./domain-service"; import { validateDomainFromEmail } from "./domain-service";
import { EmailRenderer } from "@unsend/email-editor/src/renderer"; import { EmailRenderer } from "@unsend/email-editor/src/renderer";
import { logger } from "../logger/log";
async function checkIfValidEmail(emailId: string) { async function checkIfValidEmail(emailId: string) {
const email = await db.email.findUnique({ const email = await db.email.findUnique({
@@ -155,6 +156,7 @@ export async function sendEmail(
try { try {
await EmailQueueService.queueEmail( await EmailQueueService.queueEmail(
email.id, email.id,
teamId,
domain.region, domain.region,
true, true,
undefined, undefined,
@@ -399,15 +401,16 @@ export async function sendBulkEmails(
// Prepare queue job // Prepare queue job
queueJobs.push({ queueJobs.push({
emailId: email.id, emailId: email.id,
teamId,
region: domain.region, region: domain.region,
transactional: true, // Bulk emails are still transactional transactional: true, // Bulk emails are still transactional
delay, delay,
timestamp: Date.now(), timestamp: Date.now(),
}); });
} catch (error: any) { } catch (error: any) {
console.error( logger.error(
`Failed to create email record for recipient ${to}:`, { err: error, to },
error `Failed to create email record for recipient`
); );
// Continue processing other emails // Continue processing other emails
} }

View File

@@ -1,11 +1,12 @@
import { env } from "~/env"; import { env } from "~/env";
import { logger } from "../logger/log";
export async function sendToDiscord(message: string) { export async function sendToDiscord(message: string) {
if (!env.DISCORD_WEBHOOK_URL) { 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." "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; return;
} }
@@ -19,9 +20,12 @@ export async function sendToDiscord(message: string) {
}); });
if (response.ok) { if (response.ok) {
console.log("Message sent to Discord successfully."); logger.info("Message sent to Discord successfully.");
} else { } else {
console.error("Failed to send message to Discord:", response.statusText); logger.error(
{ statusText: response.statusText },
"Failed to send message to Discord:"
);
} }
return; return;

View File

@@ -17,12 +17,14 @@ import {
DEFAULT_QUEUE_OPTIONS, DEFAULT_QUEUE_OPTIONS,
SES_WEBHOOK_QUEUE, SES_WEBHOOK_QUEUE,
} from "../queue/queue-constants"; } from "../queue/queue-constants";
import { getChildLogger, logger, withLogger } from "../logger/log";
import { randomUUID } from "crypto";
export async function parseSesHook(data: SesEvent) { export async function parseSesHook(data: SesEvent) {
const mailStatus = getEmailStatus(data); const mailStatus = getEmailStatus(data);
if (!mailStatus) { if (!mailStatus) {
console.error("Unknown email status", data); logger.error({ data }, "Unknown email status");
return false; return false;
} }
@@ -36,8 +38,14 @@ export async function parseSesHook(data: SesEvent) {
}, },
}); });
logger.setBindings({
sesEmailId,
mailId: email?.id,
teamId: email?.teamId,
});
if (!email) { if (!email) {
console.error("Email not found", data); logger.error({ data }, "Email not found");
return false; return false;
} }
@@ -286,7 +294,14 @@ export class SesHookParser {
private static worker = new Worker( private static worker = new Worker(
SES_WEBHOOK_QUEUE, SES_WEBHOOK_QUEUE,
async (job) => { async (job) => {
await this.execute(job.data); return await withLogger(
getChildLogger({
queueId: job.id ?? randomUUID(),
}),
async () => {
await this.execute(job.data);
}
);
}, },
{ {
connection: getRedis(), connection: getRedis(),

View File

@@ -6,6 +6,7 @@ import * as ses from "~/server/aws/ses";
import { EventType } from "@aws-sdk/client-sesv2"; import { EventType } from "@aws-sdk/client-sesv2";
import { EmailQueueService } from "./email-queue-service"; import { EmailQueueService } from "./email-queue-service";
import { smallNanoid } from "../nanoid"; import { smallNanoid } from "../nanoid";
import { logger } from "../logger/log";
const GENERAL_EVENTS: EventType[] = [ const GENERAL_EVENTS: EventType[] = [
"BOUNCE", "BOUNCE",
@@ -121,9 +122,12 @@ export class SesSettingsService {
setting.sesEmailRateLimit, setting.sesEmailRateLimit,
setting.transactionalQuota setting.transactionalQuota
); );
console.log( logger.info(
EmailQueueService.transactionalQueue, {
EmailQueueService.marketingQueue transactionalQueue: EmailQueueService.transactionalQueue,
marketingQueue: EmailQueueService.marketingQueue,
},
"Email queues initialized"
); );
await this.invalidateCache(); await this.invalidateCache();
@@ -132,10 +136,13 @@ export class SesSettingsService {
try { try {
await sns.deleteTopic(topicArn, region); await sns.deleteTopic(topicArn, region);
} catch (deleteError) { } 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; throw error;
} }
} }
@@ -160,9 +167,12 @@ export class SesSettingsService {
sesEmailRateLimit: sendingRateLimit, sesEmailRateLimit: sendingRateLimit,
}, },
}); });
console.log( logger.info(
EmailQueueService.transactionalQueue, {
EmailQueueService.marketingQueue transactionalQueue: EmailQueueService.transactionalQueue,
marketingQueue: EmailQueueService.marketingQueue,
},
"Email queues before update"
); );
EmailQueueService.initializeQueue( EmailQueueService.initializeQueue(
@@ -171,9 +181,12 @@ export class SesSettingsService {
setting.transactionalQuota setting.transactionalQuota
); );
console.log( logger.info(
EmailQueueService.transactionalQueue, {
EmailQueueService.marketingQueue transactionalQueue: EmailQueueService.transactionalQueue,
marketingQueue: EmailQueueService.marketingQueue,
},
"Email queues after update"
); );
await this.invalidateCache(); await this.invalidateCache();
@@ -261,7 +274,7 @@ async function registerConfigurationSet(setting: SesSetting) {
} }
async function isValidUnsendUrl(url: string) { async function isValidUnsendUrl(url: string) {
console.log("Checking if URL is valid", url); logger.info({ url }, "Checking if URL is valid");
try { try {
const response = await fetch(`${url}/api/ses_callback`, { const response = await fetch(`${url}/api/ses_callback`, {
method: "GET", method: "GET",
@@ -272,7 +285,7 @@ async function isValidUnsendUrl(url: string) {
error: response.statusText, error: response.statusText,
}; };
} catch (e) { } catch (e) {
console.log("Error checking if URL is valid", e); logger.error({ err: e }, "Error checking if URL is valid");
return { return {
isValid: false, isValid: false,
code: 500, code: 500,

132
pnpm-lock.yaml generated
View File

@@ -224,6 +224,12 @@ importers:
nodemailer: nodemailer:
specifier: ^7.0.3 specifier: ^7.0.3
version: 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: pnpm:
specifier: ^10.9.0 specifier: ^10.9.0
version: 10.9.0 version: 10.9.0
@@ -8054,6 +8060,11 @@ packages:
/asynckit@0.4.0: /asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} 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): /autoprefixer@10.4.21(postcss@8.5.3):
resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -8608,6 +8619,10 @@ packages:
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
dev: true dev: true
/colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
dev: false
/combined-stream@1.0.8: /combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -8875,6 +8890,10 @@ packages:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
dev: false dev: false
/dateformat@4.6.3:
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
dev: false
/dayjs@1.11.13: /dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
dev: false dev: false
@@ -9253,7 +9272,6 @@ packages:
resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
dependencies: dependencies:
once: 1.4.0 once: 1.4.0
dev: true
/engine.io-parser@5.2.3: /engine.io-parser@5.2.3:
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
@@ -10413,6 +10431,10 @@ packages:
- supports-color - supports-color
dev: true dev: true
/fast-copy@3.0.2:
resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==}
dev: false
/fast-deep-equal@2.0.1: /fast-deep-equal@2.0.1:
resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==} resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==}
dev: false dev: false
@@ -10460,6 +10482,15 @@ packages:
resolution: {integrity: sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==} resolution: {integrity: sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==}
dev: true 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: /fast-uri@3.0.6:
resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==}
dev: true dev: true
@@ -11292,6 +11323,10 @@ packages:
hasBin: true hasBin: true
dev: false dev: false
/help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
dev: false
/highlight.js@10.7.3: /highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
dev: false dev: false
@@ -11903,7 +11938,6 @@ packages:
/joycon@3.1.1: /joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'} engines: {node: '>=10'}
dev: true
/js-beautify@1.15.4: /js-beautify@1.15.4:
resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==}
@@ -13075,7 +13109,6 @@ packages:
/minimist@1.2.8: /minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
dev: true
/minipass@3.3.6: /minipass@3.3.6:
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
@@ -13532,6 +13565,11 @@ packages:
engines: {node: ^10.13.0 || >=12.0.0} engines: {node: ^10.13.0 || >=12.0.0}
dev: false 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: /on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -13543,7 +13581,6 @@ packages:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
dev: true
/onetime@5.1.2: /onetime@5.1.2:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
@@ -13904,6 +13941,52 @@ packages:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'} 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: /pirates@4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -14187,6 +14270,10 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
dev: false dev: false
/process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
dev: false
/progress@2.0.3: /progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
@@ -14401,7 +14488,6 @@ packages:
dependencies: dependencies:
end-of-stream: 1.4.4 end-of-stream: 1.4.4
once: 1.4.0 once: 1.4.0
dev: true
/punycode.js@2.3.1: /punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
@@ -14472,6 +14558,10 @@ packages:
/queue-microtask@1.2.3: /queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} 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: /quick-lru@5.1.1:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -14744,6 +14834,11 @@ packages:
engines: {node: '>= 14.18.0'} engines: {node: '>= 14.18.0'}
dev: true 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: /recharts-scale@0.4.5:
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
dependencies: dependencies:
@@ -15397,6 +15492,11 @@ packages:
resolution: {integrity: sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw==} resolution: {integrity: sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw==}
dev: true dev: true
/safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
dev: false
/safer-buffer@2.1.2: /safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@@ -15422,6 +15522,10 @@ packages:
kind-of: 6.0.3 kind-of: 6.0.3
dev: true dev: true
/secure-json-parse@2.7.0:
resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
dev: false
/selderee@0.11.0: /selderee@0.11.0:
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
dependencies: dependencies:
@@ -15762,6 +15866,12 @@ packages:
smart-buffer: 4.2.0 smart-buffer: 4.2.0
dev: true 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): /sonner@2.0.3(react-dom@19.1.0)(react@19.1.0):
resolution: {integrity: sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA==} resolution: {integrity: sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA==}
peerDependencies: peerDependencies:
@@ -15851,6 +15961,11 @@ packages:
engines: {node: '>=12'} engines: {node: '>=12'}
dev: false dev: false
/split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
dev: false
/sprintf-js@1.0.3: /sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
dev: true dev: true
@@ -16202,6 +16317,12 @@ packages:
dependencies: dependencies:
any-promise: 1.3.0 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: /through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
dev: true dev: true
@@ -17058,7 +17179,6 @@ packages:
/wrappy@1.0.2: /wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
dev: true
/ws@8.17.1: /ws@8.17.1:
resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==}