feat: add logger package to improve observability

This commit is contained in:
Henry
2026-03-13 22:19:45 +00:00
parent 2360c5075d
commit 3604a49a89
24 changed files with 409 additions and 121 deletions

View File

@@ -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:",

View File

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

View File

@@ -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");
}
}

View File

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

View File

@@ -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");
}
}

View File

@@ -39,6 +39,7 @@
"prettier": "@kan/prettier-config",
"dependencies": {
"@better-auth/stripe": "^1.4.6",
"@kan/logger": "workspace:^",
"better-auth": "^1.4.6"
}
}

View File

@@ -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");
}
}
},

View File

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

View File

@@ -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");
}
}

View File

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

View File

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

View File

@@ -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}`,

View File

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

View File

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

View 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"
}

View 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 });

View File

@@ -0,0 +1,6 @@
{
"extends": "@kan/tsconfig/internal-package.json",
"compilerOptions": {},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}