From e9ae0539d3536faf3b42b9fecaaae3c2bf68f95f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adel=20Rodr=C3=ADguez?= Date: Thu, 13 Aug 2026 22:27:21 -0400 Subject: [PATCH 1/3] feat: replace LogTape with evlog for wide-event logging Replace the five @logtape/* packages with evlog in @init/observability. The facade now exposes createLogger (per-app singleton init), the ambient log API, structured errors (createError/parseError), a Hono middleware that emits one wide event per request, a Sentry drain behind a batching/retry pipeline, Better Auth helpers, and a Vite plugin that strips log.debug from production builds. - API: request-scoped wide events via c.var.log/useLogger, user identity stamped on every authenticated request, structured error responses - Apps: createLogger({ service }) singletons; Vite apps also get the evlog plugin - Packages: Inngest, Drizzle, Convex, and email now log through the ambient evlog API; category loggers are gone Co-Authored-By: Claude Fable 5 --- apps/api/src/features/demo/functions.ts | 4 +- apps/api/src/routes/index.ts | 18 ++- apps/api/src/shared/auth.ts | 7 + apps/api/src/shared/env.generated.ts | 72 ++++----- apps/api/src/shared/files.ts | 5 +- apps/api/src/shared/logger.ts | 19 +-- apps/api/src/shared/middleware.ts | 2 + apps/api/src/shared/trpc.ts | 2 +- apps/api/src/shared/types.ts | 24 +-- apps/app/src/router.tsx | 2 +- apps/app/src/shared/components/error.tsx | 2 +- apps/app/src/shared/logger.ts | 7 +- apps/app/src/shared/server/middleware.ts | 10 +- apps/app/vite.config.ts | 2 + apps/desktop/src/main.tsx | 2 +- apps/desktop/src/shared/components/error.tsx | 2 +- apps/desktop/src/shared/logger.ts | 5 +- apps/desktop/vite.config.ts | 2 + apps/extension/src/entrypoints/background.ts | 2 +- apps/extension/src/shared/logger.ts | 5 +- apps/extension/wxt.config.ts | 2 + apps/mobile/src/app/_layout.tsx | 2 +- apps/mobile/src/shared/hooks.ts | 2 +- apps/mobile/src/shared/logger.ts | 4 +- bun.lock | 20 +-- docs/architecture/project-structure.md | 2 +- .../backend/src/functions/shared/convex.ts | 7 +- packages/db/src/client.ts | 9 +- packages/email/src/client.ts | 23 ++- packages/observability/README.md | 2 +- packages/observability/package.json | 12 +- packages/observability/src/logger/auth.ts | 6 + packages/observability/src/logger/drains.ts | 25 +++ packages/observability/src/logger/hono.ts | 17 +++ packages/observability/src/logger/index.ts | 144 +++++------------- .../observability/src/logger/integrations.ts | 2 - packages/observability/src/logger/utils.ts | 12 -- packages/observability/src/logger/vite.ts | 13 ++ packages/workflows/src/client.ts | 30 +++- 39 files changed, 259 insertions(+), 269 deletions(-) create mode 100644 packages/observability/src/logger/auth.ts create mode 100644 packages/observability/src/logger/drains.ts create mode 100644 packages/observability/src/logger/hono.ts delete mode 100644 packages/observability/src/logger/integrations.ts delete mode 100644 packages/observability/src/logger/utils.ts create mode 100644 packages/observability/src/logger/vite.ts diff --git a/apps/api/src/features/demo/functions.ts b/apps/api/src/features/demo/functions.ts index 2f0173a3..975b683e 100644 --- a/apps/api/src/features/demo/functions.ts +++ b/apps/api/src/features/demo/functions.ts @@ -4,10 +4,10 @@ import events from "@init/workflows/schema" export const demoFunction = inngest.createFunction( { id: "demo-function", triggers: [events.demoEmailSent] }, async ({ event, step, logger }) => { - logger.info`Demo function called ${event.name}` + logger.info(`Demo function called ${event.name}`) await step.run("demo-step", () => { - logger.info`Demo step called. This is only called once ${event.name}` + logger.info(`Demo step called. This is only called once ${event.name}`) return { success: true } }) diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 3c4db768..56496f7b 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -1,6 +1,7 @@ import { database } from "@init/db/client" import { kv } from "@init/kv/client" -import { honoLogger } from "@init/observability/logger/integrations" +import { parseError } from "@init/observability/logger" +import { withRequestLogging } from "@init/observability/logger/hono" import { captureException } from "@init/observability/monitoring" import { Scalar } from "@scalar/hono-api-reference" import { openAPIRouteHandler } from "hono-openapi" @@ -8,6 +9,7 @@ import { contextStorage } from "hono/context-storage" import { cors } from "hono/cors" import { HTTPException } from "hono/http-exception" import { secureHeaders } from "hono/secure-headers" +import type { ContentfulStatusCode } from "hono/utils/http-status" import filesRoutes from "#routes/files.ts" import healthRoutes from "#routes/health.ts" import trpcRoutes from "#routes/trpc.ts" @@ -15,14 +17,14 @@ import v1Routes from "#routes/v1/index.ts" import workflowRoutes from "#routes/workflows.ts" import { auth } from "#shared/auth.ts" import { files } from "#shared/files.ts" -import { LoggerCategory, logger } from "#shared/logger.ts" +import { drain } from "#shared/logger.ts" import { withLanguageDetection } from "#shared/middleware.ts" import { allowedOrigins, factory } from "#shared/utils.ts" const app = factory.createApp() +app.use(withRequestLogging({ drain })) app.use(withLanguageDetection) -app.use(honoLogger({ category: LoggerCategory.HONO })) app.use(contextStorage()) app.use( secureHeaders({ @@ -45,7 +47,6 @@ app.use(async (c, next) => { c.set("db", database()) c.set("files", files) c.set("kv", kv()) - c.set("logger", logger) await next() }) @@ -54,10 +55,15 @@ app.onError((error, c) => { return error.getResponse() } - c.var.logger.error(error.message) + c.var.log.error(error) captureException(error) - return c.text("Internal Server Error", 500) + const parsed = parseError(error) + + return c.json( + { fix: parsed.fix, message: parsed.message, why: parsed.why }, + parsed.status as ContentfulStatusCode + ) }) app.on(["POST", "GET"], "/auth/**", (c) => c.var.auth.handler(c.req.raw)) diff --git a/apps/api/src/shared/auth.ts b/apps/api/src/shared/auth.ts index 4670353e..a886e0cd 100644 --- a/apps/api/src/shared/auth.ts +++ b/apps/api/src/shared/auth.ts @@ -8,6 +8,7 @@ import { createAuth, databaseAdapter } from "@init/auth/server" import { admin, organization } from "@init/auth/server/plugins" import { database } from "@init/db/client" import { sendEmail } from "@init/email/client" +import { log } from "@init/observability/logger" import PasswordReset from "@init/email/templates/password-reset" import { ENV } from "#shared/env.generated.ts" import { allowedOrigins, baseUrl } from "#shared/utils.ts" @@ -27,6 +28,12 @@ export const auth = createAuth({ }) }, }, + logger: { + level: "warn", + log: (level, message, ...details) => { + log[level]({ message, scope: "auth", ...(details.length > 0 ? { details } : {}) }) + }, + }, plugins: [admin(), organization()], secret: ENV.AUTH_SECRET, session: AUTH_SESSION_OPTIONS, diff --git a/apps/api/src/shared/env.generated.ts b/apps/api/src/shared/env.generated.ts index 1f0654e4..f5348d7f 100644 --- a/apps/api/src/shared/env.generated.ts +++ b/apps/api/src/shared/env.generated.ts @@ -139,13 +139,6 @@ export type CoercedEnvSchema = { */ DATABASE_URL: string; - /** - * **RUN_PRODUCTION_MIGRATIONS** - * Allows migration tooling to modify a non-local database. - * ![icon](data:image/svg+xml;utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23808080%22%20d%3D%22M23%2023a7%207%200%201%201%207-7a7.01%207.01%200%200%201-7%207m0-12a5%205%200%201%200%205%205a5.006%205.006%200%200%200-5-5%22%2F%3E%3Ccircle%20cx%3D%229%22%20cy%3D%2216%22%20r%3D%227%22%20fill%3D%22%23808080%22%2F%3E%3C%2Fsvg%3E) - */ - RUN_PRODUCTION_MIGRATIONS: boolean; - /** * **REDIS_URL** 🔐 _sensitive_ * Redis connection URL. @@ -153,6 +146,20 @@ export type CoercedEnvSchema = { */ REDIS_URL: string; + /** + * **INNGEST_BASE_URL** + * Optional self-hosted or local Inngest endpoint. + * ![icon](data:image/svg+xml;utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23808080%22%20d%3D%22M24%2021V9h-2v14h8v-2zm-4-6v-4c0-1.103-.897-2-2-2h-6v14h2v-6h1.48l2.335%206h2.145l-2.333-6H18c1.103%200%202-.897%202-2m-6-4h4v4h-4zM8%2023H4c-1.103%200-2-.897-2-2V9h2v12h4V9h2v12c0%201.103-.897%202-2%202%22%2F%3E%3C%2Fsvg%3E) + */ + INNGEST_BASE_URL?: string; + + /** + * **INNGEST_DEV** + * Enables Inngest development mode. + * ![icon](data:image/svg+xml;utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23808080%22%20d%3D%22M23%2023a7%207%200%201%201%207-7a7.01%207.01%200%200%201-7%207m0-12a5%205%200%201%200%205%205a5.006%205.006%200%200%200-5-5%22%2F%3E%3Ccircle%20cx%3D%229%22%20cy%3D%2216%22%20r%3D%227%22%20fill%3D%22%23808080%22%2F%3E%3C%2Fsvg%3E) + */ + INNGEST_DEV: boolean; + /** * **INNGEST_EVENT_KEY** 🔐 _sensitive_ * Inngest event ingestion credential. @@ -167,20 +174,6 @@ export type CoercedEnvSchema = { */ INNGEST_SIGNING_KEY: string; - /** - * **INNGEST_DEV** - * Enables Inngest development mode. - * ![icon](data:image/svg+xml;utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23808080%22%20d%3D%22M23%2023a7%207%200%201%201%207-7a7.01%207.01%200%200%201-7%207m0-12a5%205%200%201%200%205%205a5.006%205.006%200%200%200-5-5%22%2F%3E%3Ccircle%20cx%3D%229%22%20cy%3D%2216%22%20r%3D%227%22%20fill%3D%22%23808080%22%2F%3E%3C%2Fsvg%3E) - */ - INNGEST_DEV: boolean; - - /** - * **INNGEST_BASE_URL** - * Optional self-hosted or local Inngest endpoint. - * ![icon](data:image/svg+xml;utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23808080%22%20d%3D%22M24%2021V9h-2v14h8v-2zm-4-6v-4c0-1.103-.897-2-2-2h-6v14h2v-6h1.48l2.335%206h2.145l-2.333-6H18c1.103%200%202-.897%202-2m-6-4h4v4h-4zM8%2023H4c-1.103%200-2-.897-2-2V9h2v12h4V9h2v12c0%201.103-.897%202-2%202%22%2F%3E%3C%2Fsvg%3E) - */ - INNGEST_BASE_URL?: string; - /** * **SENTRY_DSN** * Server Sentry DSN. @@ -209,20 +202,6 @@ export type CoercedEnvSchema = { */ SENTRY_PROJECT: string; - /** - * **SENTRY_DEBUG** - * Enables server Sentry diagnostics. - * ![icon](data:image/svg+xml;utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23808080%22%20d%3D%22M23%2023a7%207%200%201%201%207-7a7.01%207.01%200%200%201-7%207m0-12a5%205%200%201%200%205%205a5.006%205.006%200%200%200-5-5%22%2F%3E%3Ccircle%20cx%3D%229%22%20cy%3D%2216%22%20r%3D%227%22%20fill%3D%22%23808080%22%2F%3E%3C%2Fsvg%3E) - */ - SENTRY_DEBUG: boolean; - - /** - * **SENTRY_SPOTLIGHT** - * Enables local Sentry Spotlight. - * ![icon](data:image/svg+xml;utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23808080%22%20d%3D%22M23%2023a7%207%200%201%201%207-7a7.01%207.01%200%200%201-7%207m0-12a5%205%200%201%200%205%205a5.006%205.006%200%200%200-5-5%22%2F%3E%3Ccircle%20cx%3D%229%22%20cy%3D%2216%22%20r%3D%227%22%20fill%3D%22%23808080%22%2F%3E%3C%2Fsvg%3E) - */ - SENTRY_SPOTLIGHT: boolean; - /** * **EMAIL_FROM** * Sender used when a message does not provide one. @@ -244,6 +223,27 @@ export type CoercedEnvSchema = { */ MOCK_RESEND: boolean; + /** + * **RUN_PRODUCTION_MIGRATIONS** + * Allows migration tooling to modify a non-local database. + * ![icon](data:image/svg+xml;utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23808080%22%20d%3D%22M23%2023a7%207%200%201%201%207-7a7.01%207.01%200%200%201-7%207m0-12a5%205%200%201%200%205%205a5.006%205.006%200%200%200-5-5%22%2F%3E%3Ccircle%20cx%3D%229%22%20cy%3D%2216%22%20r%3D%227%22%20fill%3D%22%23808080%22%2F%3E%3C%2Fsvg%3E) + */ + RUN_PRODUCTION_MIGRATIONS: boolean; + + /** + * **SENTRY_DEBUG** + * Enables server Sentry diagnostics. + * ![icon](data:image/svg+xml;utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23808080%22%20d%3D%22M23%2023a7%207%200%201%201%207-7a7.01%207.01%200%200%201-7%207m0-12a5%205%200%201%200%205%205a5.006%205.006%200%200%200-5-5%22%2F%3E%3Ccircle%20cx%3D%229%22%20cy%3D%2216%22%20r%3D%227%22%20fill%3D%22%23808080%22%2F%3E%3C%2Fsvg%3E) + */ + SENTRY_DEBUG: boolean; + + /** + * **SENTRY_SPOTLIGHT** + * Enables local Sentry Spotlight. + * ![icon](data:image/svg+xml;utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23808080%22%20d%3D%22M23%2023a7%207%200%201%201%207-7a7.01%207.01%200%200%201-7%207m0-12a5%205%200%201%200%205%205a5.006%205.006%200%200%200-5-5%22%2F%3E%3Ccircle%20cx%3D%229%22%20cy%3D%2216%22%20r%3D%227%22%20fill%3D%22%23808080%22%2F%3E%3C%2Fsvg%3E) + */ + SENTRY_SPOTLIGHT: boolean; + /** * **INNGEST_SIGNING_KEY_FALLBACK** 🔐 _sensitive_ * Previous signing credential accepted during rotation. @@ -263,7 +263,7 @@ export type EnvSchemaAsStrings = { type _EnvSchemaAsStrings_3b9f7a4f = EnvSchemaAsStrings; -export type PublicCoercedEnvSchema = Readonly>; +export type PublicCoercedEnvSchema = Readonly>; // re-export of the runtime ENV proxy, typed to this package's schema export const ENV = _ENV as unknown as Readonly; diff --git a/apps/api/src/shared/files.ts b/apps/api/src/shared/files.ts index 1f7e6427..dae059a8 100644 --- a/apps/api/src/shared/files.ts +++ b/apps/api/src/shared/files.ts @@ -9,6 +9,7 @@ import { signedUrlPolicy } from "files-sdk/signed-url-policy" import { validation } from "files-sdk/validation" import type { AuthenticatedAppContext } from "#shared/types.ts" import { ENV } from "#shared/env.generated.ts" +import { logger } from "#shared/logger.ts" import { context } from "#shared/utils.ts" export const FILES_MAX_UPLOAD_SIZE = 10 * 1024 * 1024 @@ -98,7 +99,7 @@ function handleUpload(key: string, file: UploadResult | StoredFile) { target: assets.key, }) .catch((error: unknown) => { - ctx.var.logger.error(`Failed to record asset: ${String(error)}`) + logger.error({ error, key, message: "Failed to record asset" }) }) } @@ -116,6 +117,6 @@ function handleDelete(keys: string[]) { ) ) .catch((error: unknown) => { - ctx.var.logger.error(`Failed to delete asset records: ${String(error)}`) + logger.error({ error, keys, message: "Failed to delete asset records" }) }) } diff --git a/apps/api/src/shared/logger.ts b/apps/api/src/shared/logger.ts index b99f4bb6..a5b91f33 100644 --- a/apps/api/src/shared/logger.ts +++ b/apps/api/src/shared/logger.ts @@ -1,18 +1,7 @@ -import { buildLogger, LoggerCategory } from "@init/observability/logger" +import { createLogger } from "@init/observability/logger" +import { buildDrain } from "@init/observability/logger/drains" import { singleton } from "@init/utils/singleton" -export const logger = singleton("logger:api", () => - buildLogger( - [ - LoggerCategory.DEFAULT, - LoggerCategory.EMAIL, - LoggerCategory.LOGTAPE, - LoggerCategory.HONO, - LoggerCategory.DRIZZLE_ORM, - LoggerCategory.INNGEST, - ], - { async: true } - ) -) +export const drain = singleton("drain:api", () => buildDrain()) -export { LoggerCategory } from "@init/observability/logger" +export const logger = singleton("logger:api", () => createLogger({ drain, service: "api" })) diff --git a/apps/api/src/shared/middleware.ts b/apps/api/src/shared/middleware.ts index 841db380..340a2dee 100644 --- a/apps/api/src/shared/middleware.ts +++ b/apps/api/src/shared/middleware.ts @@ -1,4 +1,5 @@ import { findIp } from "@arcjet/ip" +import { identifyUser } from "@init/observability/logger/auth" import { rateLimiter } from "hono-rate-limiter" import { createMiddleware } from "hono/factory" import { HTTPException } from "hono/http-exception" @@ -24,6 +25,7 @@ export const requireSession = createMiddleware(async (c } c.set("session", session) + identifyUser(c.var.log, session) await next() }) diff --git a/apps/api/src/shared/trpc.ts b/apps/api/src/shared/trpc.ts index 8d5796c0..9fff3a2c 100644 --- a/apps/api/src/shared/trpc.ts +++ b/apps/api/src/shared/trpc.ts @@ -13,7 +13,7 @@ export function createTRPCContext(opts: FetchCreateContextFnOptions, c: Context< db: c.var.db, info: opts.info, kv: c.var.kv, - logger: c.var.logger.with({ group: "trpc" }), + log: c.var.log, req: opts.req, resHeaders: opts.resHeaders, } diff --git a/apps/api/src/shared/types.ts b/apps/api/src/shared/types.ts index 405c9972..ce5f7b0a 100644 --- a/apps/api/src/shared/types.ts +++ b/apps/api/src/shared/types.ts @@ -1,22 +1,22 @@ import type { Database } from "@init/db/client" import type { KeyValue } from "@init/kv/client" +import type { LoggerVariables } from "@init/observability/logger/hono" import type { DeepMerge } from "@init/utils/type" import type { Files } from "files-sdk" import type { Auth, Session } from "#shared/auth.ts" import type { Locale } from "#shared/internationalization/runtime.js" -import type { logger } from "#shared/logger.ts" -type AppLogger = typeof logger - -export type AppContext = { - Variables: { - auth: Auth - db: Database - files: Files - kv: KeyValue - language: Locale - logger: AppLogger +export type AppContext = DeepMerge< + LoggerVariables, + { + Variables: { + auth: Auth + db: Database + files: Files + kv: KeyValue + language: Locale + } } -} +> export type AuthenticatedAppContext = DeepMerge diff --git a/apps/app/src/router.tsx b/apps/app/src/router.tsx index d508461c..e9c0a52d 100644 --- a/apps/app/src/router.tsx +++ b/apps/app/src/router.tsx @@ -23,7 +23,7 @@ export function getRouter() { const router = createRouter({ context: { - logger: logger.getChild("router"), + logger, queryClient, } satisfies RouterContext, defaultErrorComponent: ErrorFallback, diff --git a/apps/app/src/shared/components/error.tsx b/apps/app/src/shared/components/error.tsx index d0f87bcf..44bdf6b7 100644 --- a/apps/app/src/shared/components/error.tsx +++ b/apps/app/src/shared/components/error.tsx @@ -12,7 +12,7 @@ import { logger } from "#shared/logger.ts" export default function ErrorFallback({ error, reset }: ErrorComponentProps) { useEffect(() => { - logger.error("Route rendering failed", { error }) + logger.error({ error, message: "Route rendering failed" }) }, [error]) return ( diff --git a/apps/app/src/shared/logger.ts b/apps/app/src/shared/logger.ts index 6ad07d50..3ecd7c97 100644 --- a/apps/app/src/shared/logger.ts +++ b/apps/app/src/shared/logger.ts @@ -1,10 +1,9 @@ -import { buildLogger, LoggerCategory } from "@init/observability/logger" -import { hasWindow } from "@init/utils/env" +import { createLogger } from "@init/observability/logger" import { singleton } from "@init/utils/singleton" export const logger = singleton("logger:app", () => - buildLogger([LoggerCategory.DEFAULT], { - async: !hasWindow, + createLogger({ isDevelopment: import.meta.env.DEV, + service: "app", }) ) diff --git a/apps/app/src/shared/server/middleware.ts b/apps/app/src/shared/server/middleware.ts index 38f4dc85..57518675 100644 --- a/apps/app/src/shared/server/middleware.ts +++ b/apps/app/src/shared/server/middleware.ts @@ -13,15 +13,7 @@ export const withRequestId = createMiddleware().server(({ next }) => export const withLogger = createMiddleware() .middleware([withRequestId]) - .server(({ next, context }) => - next({ - context: { - logger: logger.getChild("server-function").with({ - requestId: context.requestId, - }), - }, - }) - ) + .server(({ next }) => next({ context: { logger } })) export const withDatabase = createMiddleware().server(({ next }) => next({ context: { database: database() } }) diff --git a/apps/app/vite.config.ts b/apps/app/vite.config.ts index 1c9eb071..6bc6b5d4 100644 --- a/apps/app/vite.config.ts +++ b/apps/app/vite.config.ts @@ -1,4 +1,5 @@ import { paraglideVitePlugin as paraglide } from "@inlang/paraglide-js" +import { observability } from "@init/observability/logger/vite" import babel from "@rolldown/plugin-babel" import tailwindcss from "@tailwindcss/vite" import { devtools } from "@tanstack/devtools-vite" @@ -17,6 +18,7 @@ export default defineConfig({ plugins: [ devtools(), varlock(), + observability({ service: "app" }), tailwindcss(), tanstackStart(), react(), diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index b95980b1..86e3901d 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -20,7 +20,7 @@ const history = createHashHistory() const router = createRouter({ Wrap: ({ children }) => {children}, context: { - logger: logger.with({ group: "router" }), + logger, queryClient, } satisfies RouterContext, defaultErrorComponent: ErrorFallback, diff --git a/apps/desktop/src/shared/components/error.tsx b/apps/desktop/src/shared/components/error.tsx index ef4427af..1853bd3d 100644 --- a/apps/desktop/src/shared/components/error.tsx +++ b/apps/desktop/src/shared/components/error.tsx @@ -12,7 +12,7 @@ import { logger } from "#shared/logger.ts" export default function ErrorFallback({ error, reset }: ErrorComponentProps) { useEffect(() => { - logger.error("Route rendering failed", { error }) + logger.error({ error, message: "Route rendering failed" }) }, [error]) return ( diff --git a/apps/desktop/src/shared/logger.ts b/apps/desktop/src/shared/logger.ts index 7d071140..30018289 100644 --- a/apps/desktop/src/shared/logger.ts +++ b/apps/desktop/src/shared/logger.ts @@ -1,8 +1,9 @@ -import { buildLogger, LoggerCategory } from "@init/observability/logger" +import { createLogger } from "@init/observability/logger" import { singleton } from "@init/utils/singleton" export const logger = singleton("logger:desktop", () => - buildLogger([LoggerCategory.DEFAULT], { + createLogger({ isDevelopment: import.meta.env.DEV, + service: "desktop", }) ) diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 93dca586..eaafc173 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,4 +1,5 @@ import { paraglideVitePlugin as paraglide } from "@inlang/paraglide-js" +import { observability } from "@init/observability/logger/vite" import babel from "@rolldown/plugin-babel" import tailwindcss from "@tailwindcss/vite" import { devtools } from "@tanstack/devtools-vite" @@ -22,6 +23,7 @@ export default defineConfig(() => { plugins: [ devtools(), varlock(), + observability({ service: "desktop" }), tailwindcss(), paraglide({ outdir: "./src/shared/internationalization", diff --git a/apps/extension/src/entrypoints/background.ts b/apps/extension/src/entrypoints/background.ts index 80587614..aa1e21ed 100644 --- a/apps/extension/src/entrypoints/background.ts +++ b/apps/extension/src/entrypoints/background.ts @@ -4,7 +4,7 @@ import { logger } from "#shared/logger.ts" export default defineBackground({ main: () => { - logger.with({ id: browser.runtime.id }).info`Hello from the background script!` + logger.info({ id: browser.runtime.id, message: "Hello from the background script!" }) }, type: "module", }) diff --git a/apps/extension/src/shared/logger.ts b/apps/extension/src/shared/logger.ts index 9039af81..3d2ec918 100644 --- a/apps/extension/src/shared/logger.ts +++ b/apps/extension/src/shared/logger.ts @@ -1,8 +1,9 @@ -import { buildLogger, LoggerCategory } from "@init/observability/logger" +import { createLogger } from "@init/observability/logger" import { singleton } from "@init/utils/singleton" export const logger = singleton("logger:extension", () => - buildLogger([LoggerCategory.DEFAULT], { + createLogger({ isDevelopment: import.meta.env.DEV, + service: "extension", }) ) diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts index d37cded2..e2b8f5b6 100644 --- a/apps/extension/wxt.config.ts +++ b/apps/extension/wxt.config.ts @@ -1,4 +1,5 @@ import { paraglideVitePlugin as paraglide } from "@inlang/paraglide-js" +import { observability } from "@init/observability/logger/vite" import tailwindcss from "@tailwindcss/vite" import { varlockVitePlugin as varlock } from "@varlock/vite-integration" import { defineConfig } from "wxt" @@ -18,6 +19,7 @@ export default defineConfig({ vite: () => ({ plugins: [ varlock(), + observability({ service: "extension" }), tailwindcss(), paraglide({ outdir: "./src/shared/internationalization", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 45848beb..556a5499 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -15,7 +15,7 @@ void SplashScreen.preventAutoHideAsync() export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) { useEffect(() => { - logger.error("Route rendering failed", { error }) + logger.error({ error, message: "Route rendering failed" }) }, [error]) return ( diff --git a/apps/mobile/src/shared/hooks.ts b/apps/mobile/src/shared/hooks.ts index 1425b27e..e01487ec 100644 --- a/apps/mobile/src/shared/hooks.ts +++ b/apps/mobile/src/shared/hooks.ts @@ -12,7 +12,7 @@ export function useHideSplashScreen(loaded: boolean) { try { await SplashScreen.hideAsync() } catch (error) { - logger.warn("Error hiding splash screen:", { error }) + logger.warn({ error, message: "Error hiding splash screen" }) } } diff --git a/apps/mobile/src/shared/logger.ts b/apps/mobile/src/shared/logger.ts index b0a7ca2f..a13a1121 100644 --- a/apps/mobile/src/shared/logger.ts +++ b/apps/mobile/src/shared/logger.ts @@ -1,4 +1,4 @@ -import { buildLogger, LoggerCategory } from "@init/observability/logger" +import { createLogger } from "@init/observability/logger" import { singleton } from "@init/utils/singleton" -export const logger = singleton("logger:mobile", () => buildLogger([LoggerCategory.DEFAULT])) +export const logger = singleton("logger:mobile", () => createLogger({ service: "mobile" })) diff --git a/bun.lock b/bun.lock index 24431ff8..407b1499 100644 --- a/bun.lock +++ b/bun.lock @@ -452,22 +452,20 @@ "name": "@init/observability", "dependencies": { "@init/utils": "workspace:*", - "@logtape/drizzle-orm": "1.3.6", - "@logtape/hono": "1.3.6", - "@logtape/logtape": "^1.3.1", - "@logtape/pretty": "^1.3.1", - "@logtape/redaction": "1.3.6", "@openstatus/react": "^0.0.3", "@sentry/browser": "10.32.1", "@sentry/core": "10.32.1", "@sentry/node": "10.32.1", "@sentry/react-native": "~7.11.0", + "evlog": "^2.26.0", "varlock": "1.16.0", }, "devDependencies": { "@tooling/tsconfig": "workspace:*", "@types/react": "19.2.4", + "hono": "4.12.32", "typescript": "7.0.2", + "vite": "8.1.5", }, }, "packages/payments": { @@ -1212,16 +1210,6 @@ "@lix-js/server-protocol-schema": ["@lix-js/server-protocol-schema@0.1.1", "", {}, "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ=="], - "@logtape/drizzle-orm": ["@logtape/drizzle-orm@1.3.6", "", { "peerDependencies": { "@logtape/logtape": "^1.3.6" } }, "sha512-+dOraqf8K5FBkpJ8WtOA9hDC2MLozhnMnLGFVL7v6ZZ9AR7yamKdDZeKAIr92ICh0Gxc37aCqyVcrvz0zZ3OtA=="], - - "@logtape/hono": ["@logtape/hono@1.3.6", "", { "peerDependencies": { "@logtape/logtape": "^1.3.6", "hono": "^4.0.0" } }, "sha512-66p+eryEDgBzEgQP90NpXsPe0DR05dt2qXZwF4zVVp1NPE2U3vvcf/FRVZoEJa7BU4jGzvxckoBMutq8kDt4Jg=="], - - "@logtape/logtape": ["@logtape/logtape@1.3.11", "", {}, "sha512-mxkQOyySbjNGweavPudypBf0qZJxLOTXl2NAXYnsZF/ds5dQpTLA7YMSJbc8uFWlTkG+YjAnVL8tQWivIVPOJg=="], - - "@logtape/pretty": ["@logtape/pretty@1.3.11", "", { "peerDependencies": { "@logtape/logtape": "^1.3.11" } }, "sha512-PzCGM8Uv0usfz/0J97TOTE0/Ymip6a1kY+bIR/ldq72vNrgjfbDGJLo2F8j5ECSZ6h8HKfNDmKjIaSBQgG9JOQ=="], - - "@logtape/redaction": ["@logtape/redaction@1.3.6", "", { "peerDependencies": { "@logtape/logtape": "^1.3.6" } }, "sha512-K7wM1Ujlwhk60AKZPveK5rvQiCfCpRdvoRBzi3ii2HePbFnPfvO+L3ShM4GQB7lVxKB85tbqoX32MCbzc/zvCQ=="], - "@mdn/browser-compat-data": ["@mdn/browser-compat-data@8.0.4", "", {}, "sha512-up6DsNsaPt3jq5d2TGx4UOXIqBKU0D86dxWwcVmmOOEYqP1rFaV1XcAcwHxMvb6bbtMfY5JpjUCszzy/aVc4yA=="], "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], @@ -2992,6 +2980,8 @@ "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "evlog": ["evlog@2.26.0", "", { "peerDependencies": { "@nestjs/common": ">=11.1.28", "@nuxt/kit": "^4.4.2", "@orpc/server": ">=1.14.8", "@tanstack/start-client-core": "^1.170.14", "ai": ">=6.0.168 <8.0.0", "elysia": ">=1.4.29", "eve": ">=0.30.0", "express": ">=5.2.1", "fastify": ">=5.10.0", "h3": "^1.15.11", "hono": ">=4.12.30", "next": ">=16.2.10", "nitro": "^3.0.260311-beta", "nitropack": "^2.13.4", "ofetch": "^1.5.1", "react": ">=19.2.7", "react-router": ">=7.18.1", "vite": "^7.0.0 || ^8.0.0" }, "optionalPeers": ["@nestjs/common", "@nuxt/kit", "@orpc/server", "@tanstack/start-client-core", "ai", "elysia", "eve", "express", "fastify", "h3", "hono", "next", "nitro", "nitropack", "ofetch", "react", "react-router", "vite"] }, "sha512-5/UrfWGJEIuPdOk2m2lBqxJcDoH+71bejl/D1U4q42A/+UPPZ11h35GvVdzn6HTMfnkfbBiRIfLBR5C8Osg47g=="], + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], "expo": ["expo@57.0.10", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^57.0.12", "@expo/config": "~57.0.6", "@expo/config-plugins": "~57.0.6", "@expo/devtools": "~57.0.1", "@expo/dom-webview": "~57.0.1", "@expo/fingerprint": "^0.20.6", "@expo/local-build-cache-provider": "^57.0.5", "@expo/log-box": "^57.0.2", "@expo/metro": "~56.0.0", "@expo/metro-config": "~57.0.7", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~57.0.5", "expo-asset": "~57.0.8", "expo-constants": "~57.0.9", "expo-file-system": "~57.0.1", "expo-font": "~57.0.1", "expo-keep-awake": "~57.0.1", "expo-modules-autolinking": "~57.0.9", "expo-modules-core": "~57.0.9", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/metro-runtime": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-web": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/metro-runtime", "react-dom", "react-native-web", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-nirZEdsA4ZKkzOZX3sqzpArc2tnVAvdiFmm0gZRmIckl0FnSgC3RGp27JJZ2NzqkEaVZ5e06dFvX/NECdK5cIQ=="], diff --git a/docs/architecture/project-structure.md b/docs/architecture/project-structure.md index 1efe3ea0..c1f8933e 100644 --- a/docs/architecture/project-structure.md +++ b/docs/architecture/project-structure.md @@ -36,7 +36,7 @@ root │ ├── email # Email templating and sending service using Resend │ ├── kv # Redis client database integration using Upstash │ ├── native-ui # Reusable React Native UI components - │ ├── observability # Logging, error tracking, and monitoring using Sentry and Axiom + │ ├── observability # Wide-event logging with evlog, error tracking and monitoring with Sentry │ ├── payments # Payment processing utilities using Stripe │ ├── ui # Reusable UI components and design system using Shadcn/UI │ ├── utils # Shared helpers and constants for packages and apps diff --git a/packages/backend/src/functions/shared/convex.ts b/packages/backend/src/functions/shared/convex.ts index 74662368..404fd121 100644 --- a/packages/backend/src/functions/shared/convex.ts +++ b/packages/backend/src/functions/shared/convex.ts @@ -1,6 +1,5 @@ import { UnauthenticatedError, UnauthorizedError } from "@init/core/errors" -import { buildLogger, LoggerCategory } from "@init/observability/logger" -import { singleton } from "@init/utils/singleton" +import { log } from "@init/observability/logger" import { createBuilder } from "fluent-convex" import type { DataModel } from "#functions/_generated/dataModel.js" import type { ActionCtx, MutationCtx, QueryCtx } from "#functions/_generated/server.js" @@ -8,9 +7,7 @@ import { authComponent } from "#functions/shared/auth.ts" export const convex = createBuilder() -export const withLogger = convex.createMiddleware((ctx, next) => - next({ ...ctx, logger: singleton("logger:convex", () => buildLogger([LoggerCategory.CONVEX])) }) -) +export const withLogger = convex.createMiddleware((ctx, next) => next({ ...ctx, logger: log })) export type GenericCtx = QueryCtx | ActionCtx | MutationCtx diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index dd32f363..fb125692 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,5 +1,4 @@ -import { LoggerCategory } from "@init/observability/logger" -import { drizzleLogger } from "@init/observability/logger/integrations" +import { log } from "@init/observability/logger" import { singleton } from "@init/utils/singleton" import { SQL } from "bun" import { drizzle } from "drizzle-orm/bun-sql" @@ -12,7 +11,11 @@ export function connect(url: string) { return drizzle({ casing: "snake_case", client, - logger: drizzleLogger({ category: LoggerCategory.DRIZZLE_ORM }), + logger: { + logQuery(query, params) { + log.debug({ params, query, scope: "drizzle" }) + }, + }, schema, }) } diff --git a/packages/email/src/client.ts b/packages/email/src/client.ts index 1405c6a2..37ebd1d6 100644 --- a/packages/email/src/client.ts +++ b/packages/email/src/client.ts @@ -1,6 +1,6 @@ import type { ReactNode } from "react" import { SendEmailError, BatchSendEmailError } from "@init/core/errors" -import { getLogger, LoggerCategory } from "@init/observability/logger" +import { log } from "@init/observability/logger" import { singleton } from "@init/utils/singleton" import { render } from "@react-email/render" import { addMilliseconds } from "date-fns" @@ -17,8 +17,6 @@ type EmailSendParams = { export const email = singleton("email", () => new Resend(ENV.RESEND_API_KEY)) -const logger = getLogger(LoggerCategory.EMAIL) - export async function sendEmail(body: ReactNode, params: EmailSendParams) { const { emails, subject, sendAt, from = ENV.EMAIL_FROM } = params @@ -95,13 +93,14 @@ export async function batchEmails(payload: Array@init/observability -Observability package built with [Sentry](https://sentry.io/), [Axiom](https://axiom.co/), and [OpenStatus](https://openstatus.dev/). +Observability package built with [evlog](https://evlog.dev/), [Sentry](https://sentry.io/), and [OpenStatus](https://openstatus.dev/). diff --git a/packages/observability/package.json b/packages/observability/package.json index d2fa0d1f..136bf26d 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,7 +1,7 @@ { "name": "@init/observability", "private": true, - "description": "Logging, error tracking, and monitoring using Sentry", + "description": "Wide-event logging with evlog, error tracking and monitoring with Sentry", "license": "MIT", "type": "module", "imports": { @@ -22,21 +22,19 @@ }, "dependencies": { "@init/utils": "workspace:*", - "@logtape/drizzle-orm": "1.3.6", - "@logtape/hono": "1.3.6", - "@logtape/logtape": "^1.3.1", - "@logtape/pretty": "^1.3.1", - "@logtape/redaction": "1.3.6", "@openstatus/react": "^0.0.3", "@sentry/browser": "10.32.1", "@sentry/core": "10.32.1", "@sentry/node": "10.32.1", "@sentry/react-native": "~7.11.0", + "evlog": "^2.26.0", "varlock": "1.16.0" }, "devDependencies": { "@tooling/tsconfig": "workspace:*", "@types/react": "19.2.4", - "typescript": "7.0.2" + "hono": "4.12.32", + "typescript": "7.0.2", + "vite": "8.1.5" } } diff --git a/packages/observability/src/logger/auth.ts b/packages/observability/src/logger/auth.ts new file mode 100644 index 00000000..030cdf33 --- /dev/null +++ b/packages/observability/src/logger/auth.ts @@ -0,0 +1,6 @@ +/** + * Better Auth helpers for wide events: `identifyUser` stamps the session's + * user onto a request logger, `createAuthMiddleware` resolves sessions from + * headers, and `maskEmail` redacts addresses for safe logging. + */ +export { createAuthMiddleware, identifyUser, maskEmail } from "evlog/better-auth" diff --git a/packages/observability/src/logger/drains.ts b/packages/observability/src/logger/drains.ts new file mode 100644 index 00000000..b8b15fea --- /dev/null +++ b/packages/observability/src/logger/drains.ts @@ -0,0 +1,25 @@ +import type { DrainContext } from "evlog" +import { createDrainPipeline } from "evlog/pipeline" +import { createSentryDrain } from "evlog/sentry" +import { ENV } from "#env.generated.ts" + +/** + * Server-side drain shipping wide events to Sentry, wrapped in a pipeline + * with batching and retry. Returns `undefined` when no DSN is configured so + * apps can pass the result straight to `createLogger`. + * + * Server-only: reads the observability environment. Client apps log to the + * console and rely on Sentry's browser SDK for error capture. + */ +export function buildDrain() { + if (!ENV.SENTRY_DSN) { + return + } + + const pipeline = createDrainPipeline({ + batch: { intervalMs: 5000, size: 50 }, + retry: { maxAttempts: 3 }, + }) + + return pipeline(createSentryDrain({ dsn: ENV.SENTRY_DSN })) +} diff --git a/packages/observability/src/logger/hono.ts b/packages/observability/src/logger/hono.ts new file mode 100644 index 00000000..3f38033a --- /dev/null +++ b/packages/observability/src/logger/hono.ts @@ -0,0 +1,17 @@ +import type { EvlogHonoOptions } from "evlog/hono" +import { evlog } from "evlog/hono" + +/** + * Request-scoped wide-event logging for Hono. Opens one event per request, + * exposes it as `c.var.log` (merge `LoggerVariables` into the app context + * type), and emits it when the response goes out. + * + * Without an explicit `drain`, events fall back to the global drain + * configured through `createLogger`. + */ +export function withRequestLogging(options?: EvlogHonoOptions) { + return evlog(options) +} + +export { useLogger } from "evlog/hono" +export type { EvlogVariables as LoggerVariables } from "evlog/hono" diff --git a/packages/observability/src/logger/index.ts b/packages/observability/src/logger/index.ts index 83217f9f..f0a75c94 100644 --- a/packages/observability/src/logger/index.ts +++ b/packages/observability/src/logger/index.ts @@ -1,118 +1,44 @@ -import { isDevelopment } from "@init/utils/env" -import { - type Config, - type Logger as LogtapeLogger, - configure, - configureSync, - getConsoleSink, - getLogger as getLogtapeLogger, - jsonLinesFormatter, -} from "@logtape/logtape" -import { getPrettyFormatter } from "@logtape/pretty" -import { redactSink } from "#logger/utils.ts" +import type { DrainContext, Log, RedactConfig } from "evlog" +import { initLogger, log } from "evlog" -export const LoggerCategory = { - CONVEX: ["convex"], - DEFAULT: ["default"], - DRIZZLE_ORM: ["drizzle-orm"], - EMAIL: ["email"], - HONO: ["hono"], - INNGEST: ["inngest"], - LOGTAPE: ["logtape", "meta"], -} as const satisfies Record - -type LoggerCategoryType = (typeof LoggerCategory)[keyof typeof LoggerCategory] - -type BuildLoggerOptions = { - async?: boolean +type CreateLoggerOptions = { + /** Service name stamped on every event. */ + service: string + /** Overrides evlog's environment auto-detection (pretty output, redaction defaults). */ isDevelopment?: boolean + /** Ships emitted events to external services. Build one with `buildDrain()` from `#logger/drains.ts`. */ + drain?: (ctx: DrainContext) => void | Promise + /** PII redaction. Defaults to evlog's built-ins (on in production, off in development). */ + redact?: boolean | RedactConfig } -const LOGGER_CONFIGS = [ - { - category: LoggerCategory.LOGTAPE, - lowestLevel: "warning", - sinks: ["meta"], - }, - { - category: LoggerCategory.INNGEST, - lowestLevel: "info", - sinks: ["console"], - }, - { - category: LoggerCategory.CONVEX, - lowestLevel: "info", - sinks: ["console"], - }, - { - category: LoggerCategory.HONO, - lowestLevel: "info", - sinks: ["console"], - }, - { - category: LoggerCategory.DRIZZLE_ORM, - lowestLevel: "debug", - sinks: ["console"], - }, - { - category: LoggerCategory.EMAIL, - lowestLevel: "info", - sinks: ["console"], - }, - { - category: LoggerCategory.DEFAULT, - lowestLevel: "trace", - sinks: ["console"], - }, -] as const satisfies Config["loggers"] - -export function buildLogger( - categories: readonly LoggerCategoryType[], - options?: BuildLoggerOptions -) { - if (categories.length === 0) { - throw new Error("At least one logger category is required") - } - - const isDev = options?.isDevelopment ?? isDevelopment - const consoleSink = getConsoleSink({ - formatter: isDev - ? getPrettyFormatter({ - categoryTruncate: "middle", - categoryWidth: 15, - levelStyle: "bold", - messageStyle: "reset", - properties: true, - timestamp: "time", - }) - : jsonLinesFormatter, - nonBlocking: options?.async === true, +/** + * Configure evlog for this app and return the logger. Call once at startup, + * wrapped in `singleton` from `@init/utils/singleton`. + * + * The configuration is process-wide and last-call-wins, so each app must call + * this exactly once. Framework integrations that auto-initialize (the Vite + * plugin, Nitro module) detect an existing configuration and skip their own. + */ +export function createLogger(options: CreateLoggerOptions) { + const { service, isDevelopment, drain, redact } = options + + initLogger({ + env: { service }, + ...(isDevelopment === undefined ? {} : { pretty: isDevelopment }), + ...(drain === undefined ? {} : { drain }), + ...(redact === undefined ? {} : { redact }), }) - const configuredCategories = new Set(categories.map((category) => category.join("/"))) - - const config: Config = { - loggers: LOGGER_CONFIGS.filter((logger) => configuredCategories.has(logger.category.join("/"))), - sinks: { - console: redactSink(consoleSink), - meta: consoleSink, - }, - } - - if (options?.async) { - void configure(config) - } else { - configureSync(config) - } - - const defaultCategoryKey = LoggerCategory.DEFAULT.join("/") - const defaultCategory = categories.find((category) => category.join("/") === defaultCategoryKey) - - return getLogger(defaultCategory ?? categories[0]) + return log } -export function getLogger(category: LoggerCategoryType = LoggerCategory.DEFAULT) { - return getLogtapeLogger(category) -} +/** + * The ambient logger, for packages and non-request code. Same instance the + * app configured through `createLogger`. + */ +export { log } from "evlog" -export type Logger = LogtapeLogger +export { createError, parseError } from "evlog" +export type { DrainContext, ParsedError, RequestLogger, WideEvent } from "evlog" +export type Logger = Log diff --git a/packages/observability/src/logger/integrations.ts b/packages/observability/src/logger/integrations.ts deleted file mode 100644 index 728ad6cb..00000000 --- a/packages/observability/src/logger/integrations.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { getLogger as drizzleLogger } from "@logtape/drizzle-orm" -export { honoLogger } from "@logtape/hono" diff --git a/packages/observability/src/logger/utils.ts b/packages/observability/src/logger/utils.ts deleted file mode 100644 index 1718773a..00000000 --- a/packages/observability/src/logger/utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Sink } from "@logtape/logtape" -import { DEFAULT_REDACT_FIELDS, redactByField } from "@logtape/redaction" - -const PASSWORD_PATTERN = /pass(?:code|phrase|word)/i -const API_KEY_PATTERN = /api[-_]?key/i - -export function redactSink(sink: Sink) { - return redactByField(sink, { - action: () => "[REDACTED]", - fieldPatterns: ["secret", PASSWORD_PATTERN, API_KEY_PATTERN, ...DEFAULT_REDACT_FIELDS], - }) -} diff --git a/packages/observability/src/logger/vite.ts b/packages/observability/src/logger/vite.ts new file mode 100644 index 00000000..a27d2f52 --- /dev/null +++ b/packages/observability/src/logger/vite.ts @@ -0,0 +1,13 @@ +import type { EvlogViteOptions } from "evlog/vite" +import evlogVite from "evlog/vite" + +/** + * Vite plugin for evlog: auto-initializes the logger, strips `log.debug` + * calls from production builds, and can inject `file:line` source locations. + * + * Auto-initialization defers to an explicit `createLogger` call, so keeping + * the per-app logger singleton alongside this plugin is safe. + */ +export function observability(options: EvlogViteOptions & { service: string }) { + return evlogVite(options) +} diff --git a/packages/workflows/src/client.ts b/packages/workflows/src/client.ts index c0dffd3f..a6ac4aa7 100644 --- a/packages/workflows/src/client.ts +++ b/packages/workflows/src/client.ts @@ -1,16 +1,42 @@ -import { getLogger, LoggerCategory } from "@init/observability/logger" +import { log } from "@init/observability/logger" import { singleton } from "@init/utils/singleton" import { dependencyInjectionMiddleware, Inngest } from "inngest" import { extendedTracesMiddleware } from "inngest/experimental" export type { Events } from "#schema.ts" +// Inngest loggers receive freeform arguments; fold them into one structured event. +function toEvent(args: unknown[]): Record { + const [first, ...rest] = args + + if (typeof first === "string") { + return rest.length > 0 ? { details: rest, message: first } : { message: first } + } + + return { details: args } +} + +const workflowLogger = { + debug: (...args: unknown[]) => { + log.debug({ scope: "workflows", ...toEvent(args) }) + }, + error: (...args: unknown[]) => { + log.error({ scope: "workflows", ...toEvent(args) }) + }, + info: (...args: unknown[]) => { + log.info({ scope: "workflows", ...toEvent(args) }) + }, + warn: (...args: unknown[]) => { + log.warn({ scope: "workflows", ...toEvent(args) }) + }, +} + export const inngest = singleton( "inngest", () => new Inngest({ id: "init", - logger: getLogger(LoggerCategory.INNGEST), + logger: workflowLogger, middleware: [ dependencyInjectionMiddleware({ // Add any dependencies here From 35113373289da0a0c521f81307024cc00570ded5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adel=20Rodr=C3=ADguez?= Date: Thu, 13 Aug 2026 22:31:54 -0400 Subject: [PATCH 2/3] feat: add wide-event request middleware for TanStack Start Wire per-request wide events for the app without the evlog Nitro module: a Start request middleware (type: 'request') opens the event, records handler type, server function metadata, and response status, and emits on completion. Registered ahead of CSRF in requestMiddleware so rejected requests are logged too. The facade re-exports createRequestLogger for custom integrations. Co-Authored-By: Claude Fable 5 --- apps/app/src/shared/server/middleware.ts | 28 ++++++++++++++++++++++ apps/app/src/start.ts | 4 ++-- packages/observability/src/logger/index.ts | 8 +++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/app/src/shared/server/middleware.ts b/apps/app/src/shared/server/middleware.ts index 57518675..4119b407 100644 --- a/apps/app/src/shared/server/middleware.ts +++ b/apps/app/src/shared/server/middleware.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto" import { database } from "@init/db/client" +import { createRequestLogger } from "@init/observability/logger" import { createCsrfMiddleware, createMiddleware } from "@tanstack/react-start" import { logger } from "#shared/logger.ts" @@ -7,6 +8,33 @@ export const withCsrf = createCsrfMiddleware({ filter: (context) => context.handlerType === "serverFn", }) +/** + * Opens one wide event per server request (SSR and server function calls) + * and emits it with the response status. Handlers and server functions can + * add context through `context.log`. + */ +export const withWideEvent = createMiddleware({ type: "request" }).server( + async ({ request, pathname, next, handlerType, serverFnMeta }) => { + const log = createRequestLogger({ method: request.method, path: pathname }) + + log.set({ + handlerType, + ...(serverFnMeta ? { serverFn: { filename: serverFnMeta.filename, name: serverFnMeta.name } } : {}), + }) + + try { + const result = await next({ context: { log } }) + log.set({ status: result.response.status }) + log.emit() + return result + } catch (error) { + log.error(error instanceof Error ? error : String(error)) + log.emit() + throw error + } + } +) + export const withRequestId = createMiddleware().server(({ next }) => next({ context: { requestId: crypto.randomUUID() } }) ) diff --git a/apps/app/src/start.ts b/apps/app/src/start.ts index 13b8033d..0ec03ecb 100644 --- a/apps/app/src/start.ts +++ b/apps/app/src/start.ts @@ -1,9 +1,9 @@ import { createStart } from "@tanstack/react-start" -import { withCsrf } from "#shared/server/middleware.ts" +import { withCsrf, withWideEvent } from "#shared/server/middleware.ts" import { faultSerializer } from "#shared/server/serialization.ts" export const startInstance = createStart(() => ({ functionMiddleware: [], - requestMiddleware: [withCsrf], + requestMiddleware: [withWideEvent, withCsrf], serializationAdapters: [faultSerializer], })) diff --git a/packages/observability/src/logger/index.ts b/packages/observability/src/logger/index.ts index f0a75c94..3cfaa167 100644 --- a/packages/observability/src/logger/index.ts +++ b/packages/observability/src/logger/index.ts @@ -39,6 +39,14 @@ export function createLogger(options: CreateLoggerOptions) { */ export { log } from "evlog" +/** + * Open a request-scoped wide event for custom framework integrations + * (e.g. TanStack Start request middleware). Accumulate context with + * `set()` and call `emit()` when the response is ready — emitted events + * ship through the global drain configured by `createLogger`. + */ +export { createRequestLogger } from "evlog" + export { createError, parseError } from "evlog" export type { DrainContext, ParsedError, RequestLogger, WideEvent } from "evlog" export type Logger = Log From 7887160d3248375aca99bdf9360a9828375ca4d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adel=20Rodr=C3=ADguez?= Date: Wed, 19 Aug 2026 14:56:05 -0400 Subject: [PATCH 3/3] style: format evlog migration files Co-Authored-By: Claude Fable 5 --- apps/api/src/routes/index.ts | 2 +- apps/api/src/shared/auth.ts | 2 +- apps/app/src/shared/server/middleware.ts | 9 ++--- apps/app/vite.config.ts | 2 +- apps/desktop/vite.config.ts | 2 +- apps/extension/wxt.config.ts | 2 +- packages/observability/src/logger/auth.ts | 6 ++-- packages/observability/src/logger/drains.ts | 10 +++--- packages/observability/src/logger/hono.ts | 10 +++--- packages/observability/src/logger/index.ts | 38 +++++++++++++-------- packages/observability/src/logger/vite.ts | 8 ++--- 11 files changed, 50 insertions(+), 41 deletions(-) diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 56496f7b..0978cf55 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -1,3 +1,4 @@ +import type { ContentfulStatusCode } from "hono/utils/http-status" import { database } from "@init/db/client" import { kv } from "@init/kv/client" import { parseError } from "@init/observability/logger" @@ -9,7 +10,6 @@ import { contextStorage } from "hono/context-storage" import { cors } from "hono/cors" import { HTTPException } from "hono/http-exception" import { secureHeaders } from "hono/secure-headers" -import type { ContentfulStatusCode } from "hono/utils/http-status" import filesRoutes from "#routes/files.ts" import healthRoutes from "#routes/health.ts" import trpcRoutes from "#routes/trpc.ts" diff --git a/apps/api/src/shared/auth.ts b/apps/api/src/shared/auth.ts index a886e0cd..d28ee970 100644 --- a/apps/api/src/shared/auth.ts +++ b/apps/api/src/shared/auth.ts @@ -8,8 +8,8 @@ import { createAuth, databaseAdapter } from "@init/auth/server" import { admin, organization } from "@init/auth/server/plugins" import { database } from "@init/db/client" import { sendEmail } from "@init/email/client" -import { log } from "@init/observability/logger" import PasswordReset from "@init/email/templates/password-reset" +import { log } from "@init/observability/logger" import { ENV } from "#shared/env.generated.ts" import { allowedOrigins, baseUrl } from "#shared/utils.ts" diff --git a/apps/app/src/shared/server/middleware.ts b/apps/app/src/shared/server/middleware.ts index 4119b407..19e3094c 100644 --- a/apps/app/src/shared/server/middleware.ts +++ b/apps/app/src/shared/server/middleware.ts @@ -9,9 +9,8 @@ export const withCsrf = createCsrfMiddleware({ }) /** - * Opens one wide event per server request (SSR and server function calls) - * and emits it with the response status. Handlers and server functions can - * add context through `context.log`. + * Opens one wide event per server request (SSR and server function calls) and emits it with the + * response status. Handlers and server functions can add context through `context.log`. */ export const withWideEvent = createMiddleware({ type: "request" }).server( async ({ request, pathname, next, handlerType, serverFnMeta }) => { @@ -19,7 +18,9 @@ export const withWideEvent = createMiddleware({ type: "request" }).server( log.set({ handlerType, - ...(serverFnMeta ? { serverFn: { filename: serverFnMeta.filename, name: serverFnMeta.name } } : {}), + ...(serverFnMeta + ? { serverFn: { filename: serverFnMeta.filename, name: serverFnMeta.name } } + : {}), }) try { diff --git a/apps/app/vite.config.ts b/apps/app/vite.config.ts index 6bc6b5d4..aefd8cd5 100644 --- a/apps/app/vite.config.ts +++ b/apps/app/vite.config.ts @@ -1,5 +1,5 @@ -import { paraglideVitePlugin as paraglide } from "@inlang/paraglide-js" import { observability } from "@init/observability/logger/vite" +import { paraglideVitePlugin as paraglide } from "@inlang/paraglide-js" import babel from "@rolldown/plugin-babel" import tailwindcss from "@tailwindcss/vite" import { devtools } from "@tanstack/devtools-vite" diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index eaafc173..1cbba6c4 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,5 +1,5 @@ -import { paraglideVitePlugin as paraglide } from "@inlang/paraglide-js" import { observability } from "@init/observability/logger/vite" +import { paraglideVitePlugin as paraglide } from "@inlang/paraglide-js" import babel from "@rolldown/plugin-babel" import tailwindcss from "@tailwindcss/vite" import { devtools } from "@tanstack/devtools-vite" diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts index e2b8f5b6..20c9dd3a 100644 --- a/apps/extension/wxt.config.ts +++ b/apps/extension/wxt.config.ts @@ -1,5 +1,5 @@ -import { paraglideVitePlugin as paraglide } from "@inlang/paraglide-js" import { observability } from "@init/observability/logger/vite" +import { paraglideVitePlugin as paraglide } from "@inlang/paraglide-js" import tailwindcss from "@tailwindcss/vite" import { varlockVitePlugin as varlock } from "@varlock/vite-integration" import { defineConfig } from "wxt" diff --git a/packages/observability/src/logger/auth.ts b/packages/observability/src/logger/auth.ts index 030cdf33..442eb77e 100644 --- a/packages/observability/src/logger/auth.ts +++ b/packages/observability/src/logger/auth.ts @@ -1,6 +1,6 @@ /** - * Better Auth helpers for wide events: `identifyUser` stamps the session's - * user onto a request logger, `createAuthMiddleware` resolves sessions from - * headers, and `maskEmail` redacts addresses for safe logging. + * Better Auth helpers for wide events: `identifyUser` stamps the session's user onto a request + * logger, `createAuthMiddleware` resolves sessions from headers, and `maskEmail` redacts addresses + * for safe logging. */ export { createAuthMiddleware, identifyUser, maskEmail } from "evlog/better-auth" diff --git a/packages/observability/src/logger/drains.ts b/packages/observability/src/logger/drains.ts index b8b15fea..bcfdf279 100644 --- a/packages/observability/src/logger/drains.ts +++ b/packages/observability/src/logger/drains.ts @@ -4,12 +4,12 @@ import { createSentryDrain } from "evlog/sentry" import { ENV } from "#env.generated.ts" /** - * Server-side drain shipping wide events to Sentry, wrapped in a pipeline - * with batching and retry. Returns `undefined` when no DSN is configured so - * apps can pass the result straight to `createLogger`. + * Server-side drain shipping wide events to Sentry, wrapped in a pipeline with batching and retry. + * Returns `undefined` when no DSN is configured so apps can pass the result straight to + * `createLogger`. * - * Server-only: reads the observability environment. Client apps log to the - * console and rely on Sentry's browser SDK for error capture. + * Server-only: reads the observability environment. Client apps log to the console and rely on + * Sentry's browser SDK for error capture. */ export function buildDrain() { if (!ENV.SENTRY_DSN) { diff --git a/packages/observability/src/logger/hono.ts b/packages/observability/src/logger/hono.ts index 3f38033a..bea54504 100644 --- a/packages/observability/src/logger/hono.ts +++ b/packages/observability/src/logger/hono.ts @@ -2,12 +2,12 @@ import type { EvlogHonoOptions } from "evlog/hono" import { evlog } from "evlog/hono" /** - * Request-scoped wide-event logging for Hono. Opens one event per request, - * exposes it as `c.var.log` (merge `LoggerVariables` into the app context - * type), and emits it when the response goes out. + * Request-scoped wide-event logging for Hono. Opens one event per request, exposes it as + * `c.var.log` (merge `LoggerVariables` into the app context type), and emits it when the response + * goes out. * - * Without an explicit `drain`, events fall back to the global drain - * configured through `createLogger`. + * Without an explicit `drain`, events fall back to the global drain configured through + * `createLogger`. */ export function withRequestLogging(options?: EvlogHonoOptions) { return evlog(options) diff --git a/packages/observability/src/logger/index.ts b/packages/observability/src/logger/index.ts index 3cfaa167..d3570ed7 100644 --- a/packages/observability/src/logger/index.ts +++ b/packages/observability/src/logger/index.ts @@ -2,23 +2,32 @@ import type { DrainContext, Log, RedactConfig } from "evlog" import { initLogger, log } from "evlog" type CreateLoggerOptions = { - /** Service name stamped on every event. */ + /** + * Service name stamped on every event. + */ service: string - /** Overrides evlog's environment auto-detection (pretty output, redaction defaults). */ + /** + * Overrides evlog's environment auto-detection (pretty output, redaction defaults). + */ isDevelopment?: boolean - /** Ships emitted events to external services. Build one with `buildDrain()` from `#logger/drains.ts`. */ + /** + * Ships emitted events to external services. Build one with `buildDrain()` from + * `#logger/drains.ts`. + */ drain?: (ctx: DrainContext) => void | Promise - /** PII redaction. Defaults to evlog's built-ins (on in production, off in development). */ + /** + * PII redaction. Defaults to evlog's built-ins (on in production, off in development). + */ redact?: boolean | RedactConfig } /** - * Configure evlog for this app and return the logger. Call once at startup, - * wrapped in `singleton` from `@init/utils/singleton`. + * Configure evlog for this app and return the logger. Call once at startup, wrapped in `singleton` + * from `@init/utils/singleton`. * - * The configuration is process-wide and last-call-wins, so each app must call - * this exactly once. Framework integrations that auto-initialize (the Vite - * plugin, Nitro module) detect an existing configuration and skip their own. + * The configuration is process-wide and last-call-wins, so each app must call this exactly once. + * Framework integrations that auto-initialize (the Vite plugin, Nitro module) detect an existing + * configuration and skip their own. */ export function createLogger(options: CreateLoggerOptions) { const { service, isDevelopment, drain, redact } = options @@ -34,16 +43,15 @@ export function createLogger(options: CreateLoggerOptions) { } /** - * The ambient logger, for packages and non-request code. Same instance the - * app configured through `createLogger`. + * The ambient logger, for packages and non-request code. Same instance the app configured through + * `createLogger`. */ export { log } from "evlog" /** - * Open a request-scoped wide event for custom framework integrations - * (e.g. TanStack Start request middleware). Accumulate context with - * `set()` and call `emit()` when the response is ready — emitted events - * ship through the global drain configured by `createLogger`. + * Open a request-scoped wide event for custom framework integrations (e.g. TanStack Start request + * middleware). Accumulate context with `set()` and call `emit()` when the response is ready — + * emitted events ship through the global drain configured by `createLogger`. */ export { createRequestLogger } from "evlog" diff --git a/packages/observability/src/logger/vite.ts b/packages/observability/src/logger/vite.ts index a27d2f52..10b6264b 100644 --- a/packages/observability/src/logger/vite.ts +++ b/packages/observability/src/logger/vite.ts @@ -2,11 +2,11 @@ import type { EvlogViteOptions } from "evlog/vite" import evlogVite from "evlog/vite" /** - * Vite plugin for evlog: auto-initializes the logger, strips `log.debug` - * calls from production builds, and can inject `file:line` source locations. + * Vite plugin for evlog: auto-initializes the logger, strips `log.debug` calls from production + * builds, and can inject `file:line` source locations. * - * Auto-initialization defers to an explicit `createLogger` call, so keeping - * the per-app logger singleton alongside this plugin is safe. + * Auto-initialization defers to an explicit `createLogger` call, so keeping the per-app logger + * singleton alongside this plugin is safe. */ export function observability(options: EvlogViteOptions & { service: string }) { return evlogVite(options)