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..0978cf55 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -1,6 +1,8 @@ +import type { ContentfulStatusCode } from "hono/utils/http-status" 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" @@ -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..d28ee970 100644 --- a/apps/api/src/shared/auth.ts +++ b/apps/api/src/shared/auth.ts @@ -9,6 +9,7 @@ import { admin, organization } from "@init/auth/server/plugins" import { database } from "@init/db/client" import { sendEmail } from "@init/email/client" 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" @@ -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..19e3094c 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,21 +8,41 @@ 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() } }) ) 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/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/apps/app/vite.config.ts b/apps/app/vite.config.ts index 1c9eb071..aefd8cd5 100644 --- a/apps/app/vite.config.ts +++ b/apps/app/vite.config.ts @@ -1,3 +1,4 @@ +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" @@ -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..1cbba6c4 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,3 +1,4 @@ +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" @@ -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..20c9dd3a 100644 --- a/apps/extension/wxt.config.ts +++ b/apps/extension/wxt.config.ts @@ -1,3 +1,4 @@ +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" @@ -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..442eb77e --- /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..bcfdf279 --- /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..bea54504 --- /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..d3570ed7 100644 --- a/packages/observability/src/logger/index.ts +++ b/packages/observability/src/logger/index.ts @@ -1,118 +1,60 @@ -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" - -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 +import type { DrainContext, Log, RedactConfig } from "evlog" +import { initLogger, log } from "evlog" + +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]) -} - -export function getLogger(category: LoggerCategoryType = LoggerCategory.DEFAULT) { - return getLogtapeLogger(category) + return log } -export type Logger = LogtapeLogger +/** + * 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`. + */ +export { createRequestLogger } from "evlog" + +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..10b6264b --- /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