feat: add logger package to improve observability
This commit is contained in:
@@ -48,6 +48,7 @@
|
||||
"@kan/auth": "workspace:*",
|
||||
"@kan/db": "workspace:*",
|
||||
"@kan/email": "workspace:^",
|
||||
"@kan/logger": "workspace:^",
|
||||
"@kan/shared": "workspace:^",
|
||||
"@kan/stripe": "workspace:^",
|
||||
"@trpc/server": "catalog:",
|
||||
|
||||
@@ -9,6 +9,9 @@ import { ZodError } from "zod";
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import { initAuth } from "@kan/auth/server";
|
||||
import { createDrizzleClient } from "@kan/db/client";
|
||||
import { createLogger } from "@kan/logger";
|
||||
|
||||
const log = createLogger("trpc");
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
@@ -90,7 +93,7 @@ export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
|
||||
try {
|
||||
session = await auth.api.getSession();
|
||||
} catch (error) {
|
||||
console.error("Error getting session, ", error);
|
||||
log.error({ err: error }, "Error getting session");
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -118,7 +121,23 @@ export const createTRPCRouter = t.router;
|
||||
|
||||
export const createCallerFactory = t.createCallerFactory;
|
||||
|
||||
export const publicProcedure = t.procedure.meta({
|
||||
const loggingMiddleware = t.middleware(async ({ path, type, next, ctx }) => {
|
||||
const start = Date.now();
|
||||
const result = await next();
|
||||
const duration = Date.now() - start;
|
||||
|
||||
const meta = { procedure: path, type, duration, userId: (ctx as { user?: { id: string } }).user?.id };
|
||||
|
||||
if (result.ok) {
|
||||
log.info(meta, "tRPC OK");
|
||||
} else {
|
||||
log.error({ ...meta, err: result.error }, "tRPC error");
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
export const publicProcedure = t.procedure.use(loggingMiddleware).meta({
|
||||
openapi: { method: "GET", path: "/public" },
|
||||
});
|
||||
|
||||
@@ -142,14 +161,18 @@ const enforceUserIsAdmin = t.middleware(async ({ ctx, next }) => {
|
||||
});
|
||||
});
|
||||
|
||||
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed).meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/protected",
|
||||
},
|
||||
});
|
||||
export const protectedProcedure = t.procedure
|
||||
.use(loggingMiddleware)
|
||||
.use(enforceUserIsAuthed)
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/protected",
|
||||
},
|
||||
});
|
||||
|
||||
export const adminProtectedProcedure = t.procedure
|
||||
.use(loggingMiddleware)
|
||||
.use(enforceUserIsAdmin)
|
||||
.meta({
|
||||
openapi: {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import { createLogger } from "@kan/logger";
|
||||
|
||||
const log = createLogger("notifications");
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as notificationRepo from "@kan/db/repository/notification.repo";
|
||||
@@ -72,6 +75,7 @@ export async function sendMentionEmails({
|
||||
const baseUrl = env("NEXT_PUBLIC_BASE_URL");
|
||||
const cardUrl = `${baseUrl}/cards/${cardPublicId}`;
|
||||
|
||||
log.info({ cardPublicId, mentionCount: membersToNotify.length, commenterUserId }, "Sending mention emails");
|
||||
// Send emails to all mentioned members (only if notification doesn't exist)
|
||||
await Promise.all(
|
||||
membersToNotify.map(async (member) => {
|
||||
@@ -91,6 +95,7 @@ export async function sendMentionEmails({
|
||||
|
||||
// If notification already exists, skip sending email
|
||||
if (notificationExists) {
|
||||
log.debug({ email, cardPublicId }, "Skipping duplicate mention email");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,20 +119,14 @@ export async function sendMentionEmails({
|
||||
cardUrl,
|
||||
},
|
||||
);
|
||||
log.info({ email, cardPublicId }, "Mention email sent");
|
||||
} catch (error) {
|
||||
console.error("Failed to send mention email:", {
|
||||
email,
|
||||
cardPublicId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
log.error({ err: error, email, cardPublicId }, "Failed to send mention email");
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error sending mention emails:", {
|
||||
cardPublicId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
log.error({ err: error, cardPublicId }, "Error sending mention emails");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
} from "rate-limiter-flexible";
|
||||
|
||||
import { getRedisClient } from "@kan/db/redis";
|
||||
import { createLogger } from "@kan/logger";
|
||||
|
||||
const log = createLogger("rateLimit");
|
||||
|
||||
export interface RateLimitOptions {
|
||||
points?: number;
|
||||
@@ -45,7 +48,7 @@ function createRateLimiter(options: RateLimitOptions = {}) {
|
||||
|
||||
// Use Redis if available, otherwise fall back to in-memory storage
|
||||
if (redis) {
|
||||
console.log("Using Redis for rate limiting");
|
||||
log.debug("Using Redis for rate limiting");
|
||||
return new RateLimiterRedis({
|
||||
storeClient: redis,
|
||||
points,
|
||||
@@ -53,7 +56,7 @@ function createRateLimiter(options: RateLimitOptions = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Using in-memory for rate limiting");
|
||||
log.debug("Redis unavailable, falling back to in-memory rate limiting");
|
||||
return new RateLimiterMemory({
|
||||
points,
|
||||
duration,
|
||||
|
||||
@@ -4,6 +4,9 @@ import { z } from "zod";
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import type { WebhookEvent } from "@kan/db/schema";
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
import { createLogger } from "@kan/logger";
|
||||
|
||||
const log = createLogger("webhook");
|
||||
|
||||
export type WebhookEventType = WebhookEvent;
|
||||
|
||||
@@ -199,9 +202,9 @@ export async function sendWebhooksForWorkspace(
|
||||
sendWebhookToUrl(webhook.url, webhook.secret ?? undefined, payload).then(
|
||||
(result) => {
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
`Webhook delivery failed to ${webhook.url}: ${result.error}`,
|
||||
);
|
||||
log.error({ url: webhook.url, event: payload.event, error: result.error, statusCode: result.statusCode }, "Webhook delivery failed");
|
||||
} else {
|
||||
log.info({ url: webhook.url, event: payload.event, statusCode: result.statusCode }, "Webhook delivered");
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -210,7 +213,7 @@ export async function sendWebhooksForWorkspace(
|
||||
// Wait for all to complete but don't block on failures
|
||||
await Promise.allSettled(promises);
|
||||
} catch (error) {
|
||||
console.error("Failed to send webhooks for workspace:", error);
|
||||
log.error({ err: error, workspaceId }, "Failed to send webhooks for workspace");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"prettier": "@kan/prettier-config",
|
||||
"dependencies": {
|
||||
"@better-auth/stripe": "^1.4.6",
|
||||
"@kan/logger": "workspace:^",
|
||||
"better-auth": "^1.4.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ import type { dbClient } from "@kan/db/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import { notificationClient } from "@kan/email";
|
||||
import { createLogger } from "@kan/logger";
|
||||
import { createEmailUnsubscribeLink, createS3Client } from "@kan/shared";
|
||||
|
||||
const log = createLogger("auth");
|
||||
|
||||
import { downloadImage } from "./utils";
|
||||
|
||||
type BetterAuthUser = {
|
||||
@@ -103,6 +106,7 @@ export function createDatabaseHooks(db: dbClient) {
|
||||
|
||||
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
|
||||
|
||||
log.info({ workflowId: "user-signup", userId: user.id, email: user.email }, "Triggering Novu workflow");
|
||||
await notificationClient.trigger({
|
||||
to: {
|
||||
subscriberId: user.id,
|
||||
@@ -122,6 +126,7 @@ export function createDatabaseHooks(db: dbClient) {
|
||||
},
|
||||
workflowId: "user-signup",
|
||||
});
|
||||
log.info({ workflowId: "user-signup", userId: user.id }, "Novu workflow triggered");
|
||||
|
||||
await notificationClient.subscribers.credentials.update(
|
||||
{
|
||||
@@ -134,7 +139,7 @@ export function createDatabaseHooks(db: dbClient) {
|
||||
user.id,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error adding user to notification client", error);
|
||||
log.error({ err: error }, "Error adding user to notification client");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,7 +8,10 @@ import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { sendEmail } from "@kan/email";
|
||||
import { createLogger } from "@kan/logger";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
const log = createLogger("auth");
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
import { socialProvidersPlugin } from "./providers";
|
||||
@@ -101,9 +104,7 @@ export function createPlugins(db: dbClient) {
|
||||
unlimitedSeats: true,
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
`Pro subscription ${stripeSubscription.id} activated with unlimited seats`,
|
||||
);
|
||||
log.info({ subscriptionId: stripeSubscription.id }, "Pro subscription activated with unlimited seats");
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
@@ -175,11 +176,7 @@ export function createPlugins(db: dbClient) {
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
console.log("Sending magic link to:", email, "URL:", url);
|
||||
console.log(
|
||||
"Magic link contains invite:",
|
||||
decodedUrl.includes("type=invite"),
|
||||
);
|
||||
log.info({ email, isInvite: decodedUrl.includes("type=invite") }, "Sending magic link");
|
||||
if (decodedUrl.includes("type=invite")) {
|
||||
let inviterName = "";
|
||||
let workspaceName = "";
|
||||
@@ -211,7 +208,7 @@ export function createPlugins(db: dbClient) {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch invite details:", error);
|
||||
log.error({ err: error }, "Failed to fetch invite details");
|
||||
}
|
||||
|
||||
await sendEmail(
|
||||
@@ -239,11 +236,7 @@ export function createPlugins(db: dbClient) {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending magic link:", {
|
||||
email,
|
||||
url,
|
||||
error,
|
||||
});
|
||||
log.error({ err: error, email }, "Error sending magic link");
|
||||
}
|
||||
},
|
||||
}),
|
||||
@@ -273,7 +266,7 @@ export function createPlugins(db: dbClient) {
|
||||
picture?: string;
|
||||
avatar?: string;
|
||||
}) => {
|
||||
console.log("OIDC profile:", profile);
|
||||
log.debug({ profile }, "OIDC profile received");
|
||||
|
||||
const name =
|
||||
profile.name ??
|
||||
|
||||
@@ -4,8 +4,11 @@ import type Stripe from "stripe";
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import { notificationClient } from "@kan/email";
|
||||
import { createLogger } from "@kan/logger";
|
||||
import { createEmailUnsubscribeLink } from "@kan/shared";
|
||||
|
||||
const log = createLogger("auth");
|
||||
|
||||
export async function downloadImage(url: string): Promise<Buffer> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
@@ -32,6 +35,7 @@ export async function triggerWorkflow(
|
||||
|
||||
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
|
||||
|
||||
log.info({ workflowId, userId: user.id }, "Triggering Novu workflow");
|
||||
await notificationClient.trigger({
|
||||
to: {
|
||||
subscriberId: user.id,
|
||||
@@ -43,7 +47,8 @@ export async function triggerWorkflow(
|
||||
},
|
||||
workflowId,
|
||||
});
|
||||
log.info({ workflowId, userId: user.id }, "Novu workflow triggered");
|
||||
} catch (error) {
|
||||
console.error("Error triggering workflow", error);
|
||||
log.error({ err: error, workflowId }, "Error triggering workflow");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@electric-sql/pglite": "^0.3.7",
|
||||
"@kan/logger": "workspace:^",
|
||||
"@kan/shared": "workspace:^",
|
||||
"drizzle-orm": "^0.42.0",
|
||||
"drizzle-zod": "^0.5.1",
|
||||
|
||||
@@ -6,8 +6,12 @@ import { drizzle as drizzlePgLite } from "drizzle-orm/pglite";
|
||||
import { migrate } from "drizzle-orm/pglite/migrator";
|
||||
import { Pool } from "pg";
|
||||
|
||||
import { createLogger } from "@kan/logger";
|
||||
|
||||
import * as schema from "./schema";
|
||||
|
||||
const log = createLogger("db");
|
||||
|
||||
export type dbClient = NodePgDatabase<typeof schema> & {
|
||||
$client: Pool;
|
||||
};
|
||||
@@ -16,7 +20,7 @@ export const createDrizzleClient = (): dbClient => {
|
||||
const connectionString = process.env.POSTGRES_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
console.log("POSTGRES_URL environment variable is not set, using PGLite");
|
||||
log.warn("POSTGRES_URL not set, falling back to PGLite");
|
||||
|
||||
const client = new PGlite({
|
||||
dataDir: "./pgdata",
|
||||
|
||||
@@ -402,8 +402,6 @@ export const softDeleteById = async (
|
||||
.groupBy(lists.index)
|
||||
.having(gt(countExpr, 1));
|
||||
|
||||
console.log(duplicateIndices);
|
||||
|
||||
if (duplicateIndices.length > 0) {
|
||||
throw new Error(
|
||||
`Duplicate indices found after reordering in board ${result.boardId}`,
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kan/logger": "workspace:^",
|
||||
"@novu/api": "^3.11.0",
|
||||
"@react-email/components": "^1.0.1",
|
||||
"nodemailer": "^7.0.3",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { render } from "@react-email/render";
|
||||
import nodemailer from "nodemailer";
|
||||
import { createLogger } from "@kan/logger";
|
||||
|
||||
const log = createLogger("email");
|
||||
|
||||
import JoinWorkspaceTemplate from "./templates/join-workspace";
|
||||
import MagicLinkTemplate from "./templates/magic-link";
|
||||
@@ -44,6 +47,7 @@ export const sendEmail = async (
|
||||
template: Templates,
|
||||
data: Record<string, string>,
|
||||
) => {
|
||||
log.info({ to, subject, template }, "Sending email");
|
||||
try {
|
||||
const EmailTemplate = emailTemplates[template];
|
||||
|
||||
@@ -62,16 +66,10 @@ export const sendEmail = async (
|
||||
throw new Error(`Failed to send email: ${response.response}`);
|
||||
}
|
||||
|
||||
log.info({ to, subject, template, messageId: response.messageId }, "Email sent");
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Email sending failed:", {
|
||||
to,
|
||||
from: process.env.EMAIL_FROM,
|
||||
subject,
|
||||
template,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
log.error({ err: error, to, from: process.env.EMAIL_FROM, subject, template }, "Email sending failed");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
34
packages/logger/package.json
Normal file
34
packages/logger/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@kan/logger",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "git clean -xdf .cache .turbo dist node_modules",
|
||||
"dev": "tsc",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||
},
|
||||
"dependencies": {
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kan/eslint-config": "workspace:*",
|
||||
"@kan/prettier-config": "workspace:*",
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"prettier": "@kan/prettier-config"
|
||||
}
|
||||
20
packages/logger/src/index.ts
Normal file
20
packages/logger/src/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import pino from "pino";
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const level = process.env.LOG_LEVEL ?? (isDev ? "debug" : "info");
|
||||
|
||||
export const logger = pino({
|
||||
level,
|
||||
...(isDev && {
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
ignore: "pid,hostname",
|
||||
translateTime: "HH:MM:ss",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const createLogger = (module: string) => logger.child({ module });
|
||||
6
packages/logger/tsconfig.json
Normal file
6
packages/logger/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@kan/tsconfig/internal-package.json",
|
||||
"compilerOptions": {},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user