add logging (#187)
This commit is contained in:
28
.github/workflows/opencode.yml
vendored
Normal file
28
.github/workflows/opencode.yml
vendored
Normal 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
|
@@ -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",
|
||||
|
@@ -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";
|
||||
|
@@ -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) };
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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 }) => {
|
||||
|
@@ -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;
|
||||
|
@@ -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"],
|
||||
|
@@ -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`);
|
||||
});
|
||||
|
78
apps/web/src/server/logger/log.ts
Normal file
78
apps/web/src/server/logger/log.ts
Normal 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 });
|
||||
}
|
@@ -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 {
|
||||
|
@@ -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: {
|
||||
|
@@ -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 };
|
||||
};
|
||||
|
@@ -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();
|
||||
}
|
||||
|
24
apps/web/src/server/queue/bullmq-context.ts
Normal file
24
apps/web/src/server/queue/bullmq-context.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -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<CampaignEmailJob>;
|
||||
|
||||
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<QueueCampaignEmailJob>(
|
||||
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,
|
||||
},
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
@@ -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<string, Queue>();
|
||||
public static transactionalQueue = new Map<string, Queue<QueueEmailJob>>();
|
||||
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>();
|
||||
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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(),
|
||||
|
@@ -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,
|
||||
|
132
pnpm-lock.yaml
generated
132
pnpm-lock.yaml
generated
@@ -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==}
|
||||
|
Reference in New Issue
Block a user