Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/api/src/features/demo/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
})

Expand Down
18 changes: 12 additions & 6 deletions apps/api/src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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({
Expand All @@ -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()
})

Expand All @@ -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
)
Comment on lines +61 to +66

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseError returns a plain Error's message verbatim (dist/runtime/utils/parseError.mjs: if (error instanceof Error) return { message: error.message, … }), so every unhandled exception now echoes its internal text to the caller — Postgres errors carrying the failing statement, S3 SDK errors carrying bucket and endpoint, upstream fetch failures carrying internal hostnames. The old c.text("Internal Server Error", 500) disclosed none of that.

HTTPExceptions already short-circuit above, so the structured body only helps for errors that were never given why/fix in the first place.

Technical details
# Unhandled API errors echo their internal message to clients

## Affected sites
- `apps/api/src/routes/index.ts:61-66``parseError(error).message` is a plain `Error`'s `message` with no sanitisation; `parsed.status` is `error.status ?? error.statusCode` (falling back to 500 only when non-finite), so an upstream `fetch` failure carrying `status: 401` also mislabels a server fault as a client one.

## Required outcome
- Unhandled, non-`HTTPException` errors return a generic message and a 500, matching the pre-migration behaviour.
- Structured `message`/`why`/`fix` still reach the client for errors the codebase deliberately raised through `createError`.
- The full parsed error keeps going to `c.var.log.error` and `captureException`; only the response body is narrowed.

## Suggested approach
`EvlogError.isEvlogError(error)` (exported from `evlog`, and preferred over `instanceof` because it survives duplicate installs) distinguishes a deliberately structured error from an incidental one. Serialize the parsed fields only in the first case, and fall back to a fixed `{ message: "Internal Server Error" }` at 500 otherwise.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrigido: streak que começou DEPOIS do último sucesso restampa escalation_asked_at, então o sweep de retirada não cancela mais a pergunta que o refresh acabou de reescrever.

🤖 Addressed by Claude Code

})

app.on(["POST", "GET"], "/auth/**", (c) => c.var.auth.handler(c.req.raw))
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/shared/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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,
Expand Down
72 changes: 36 additions & 36 deletions apps/api/src/shared/env.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,20 +139,27 @@ 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.
* ![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)
*/
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.
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -263,7 +263,7 @@ export type EnvSchemaAsStrings = {

type _EnvSchemaAsStrings_3b9f7a4f = EnvSchemaAsStrings;

export type PublicCoercedEnvSchema = Readonly<Pick<CoercedEnvSchema, 'BASE_URL' | 'ALLOWED_API_ORIGINS' | 'PORT' | 'S3_BUCKET' | 'S3_ENDPOINT' | 'S3_REGION' | 'PORTLESS_URL' | 'AUTH_TRUSTED_ORIGINS' | 'GITHUB_CLIENT_ID' | 'GOOGLE_CLIENT_ID' | 'RUN_PRODUCTION_MIGRATIONS' | 'INNGEST_DEV' | 'INNGEST_BASE_URL' | 'SENTRY_DSN' | 'SENTRY_ORG' | 'SENTRY_PROJECT' | 'SENTRY_DEBUG' | 'SENTRY_SPOTLIGHT' | 'EMAIL_FROM' | 'MOCK_RESEND'>>;
export type PublicCoercedEnvSchema = Readonly<Pick<CoercedEnvSchema, 'BASE_URL' | 'ALLOWED_API_ORIGINS' | 'PORT' | 'S3_BUCKET' | 'S3_ENDPOINT' | 'S3_REGION' | 'PORTLESS_URL' | 'AUTH_TRUSTED_ORIGINS' | 'GITHUB_CLIENT_ID' | 'GOOGLE_CLIENT_ID' | 'INNGEST_BASE_URL' | 'INNGEST_DEV' | 'SENTRY_DSN' | 'SENTRY_ORG' | 'SENTRY_PROJECT' | 'EMAIL_FROM' | 'MOCK_RESEND' | 'RUN_PRODUCTION_MIGRATIONS' | 'SENTRY_DEBUG' | 'SENTRY_SPOTLIGHT'>>;

// re-export of the runtime ENV proxy, typed to this package's schema
export const ENV = _ENV as unknown as Readonly<CoercedEnvSchema>;
5 changes: 3 additions & 2 deletions apps/api/src/shared/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" })
})
}

Expand All @@ -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" })
})
}
19 changes: 4 additions & 15 deletions apps/api/src/shared/logger.ts
Original file line number Diff line number Diff line change
@@ -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" }))
2 changes: 2 additions & 0 deletions apps/api/src/shared/middleware.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -24,6 +25,7 @@ export const requireSession = createMiddleware<AuthenticatedAppContext>(async (c
}

c.set("session", session)
identifyUser(c.var.log, session)

await next()
})
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/shared/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
24 changes: 12 additions & 12 deletions apps/api/src/shared/types.ts
Original file line number Diff line number Diff line change
@@ -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<AppContext, { Variables: { session: Session } }>
2 changes: 1 addition & 1 deletion apps/app/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function getRouter() {

const router = createRouter({
context: {
logger: logger.getChild("router"),
logger,
queryClient,
} satisfies RouterContext,
defaultErrorComponent: ErrorFallback,
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/shared/components/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
7 changes: 3 additions & 4 deletions apps/app/src/shared/logger.ts
Original file line number Diff line number Diff line change
@@ -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",
})
)
39 changes: 30 additions & 9 deletions apps/app/src/shared/server/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,48 @@
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"

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()
Comment on lines +27 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleServerAction in @tanstack/start-server-core@1.169.17 catches every thrown error and returns new Response(serializedError, { status: response.status ?? 500 }), so next() resolves normally and the catch below never fires for server-function failures. Since nothing then calls log.error or log.setLevel, emit() computes level as "info" — a failing server function produces an info-level wide event carrying status: 500.

Deriving the level from the status closes the gap without touching the catch, which is still reachable on the SSR path.

Suggested change
const result = await next({ context: { log } })
log.set({ status: result.response.status })
log.emit()
const result = await next({ context: { log } })
const { status } = result.response
if (status >= 500) {
log.setLevel("error")
}
log.set({ 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 } }))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

withLogger now injects the ambient app singleton as context.logger, while withWideEvent injects the request logger as context.log. publicFunction in shared/server/functions.ts wires only withLogger, so anything a server function logs emits as its own standalone event instead of accumulating on the request's wide event — the scattered-log-lines pattern this PR set out to replace. withRequestId also still mints a requestId that no longer reaches any logger.

Technical details
# Server functions log through the ambient logger, bypassing the wide event

## Affected sites
- `apps/app/src/shared/server/middleware.ts:44``withLogger` reduced to `next({ context: { logger } })`, handing over the process-wide singleton.
- `apps/app/src/shared/server/middleware.ts:38-40``withRequestId`'s `crypto.randomUUID()` is no longer consumed by anything now that `withLogger` dropped its `.with({ requestId })` call.
- `apps/app/src/shared/server/functions.ts:4``publicFunction` composes `[withLogger, withDatabase]`, so `context.logger` is what every server function actually receives.
- `apps/app/src/shared/server/middleware.ts:12-15` — the JSDoc says "Handlers and server functions can add context through `context.log`", but request-middleware context does not reach `beforeLoad`/`loader` (TanStack/router#6395) and this app defines no route `server.handlers`, so server functions are the only possible consumer — and they are wired to the other logger.

## Required outcome
- One logger is reachable from server functions, and writes to it land on the request's wide event.
- No middleware remains whose only output nothing reads.

## Suggested approach
Dropping `withLogger` and `withRequestId` and letting server functions read `context.log` from `withWideEvent` is the smallest change; `withLogger` could alternatively be kept as a compatibility alias that forwards `context.log`. Either way the JSDoc should say plainly that loaders and `beforeLoad` do not receive this context.

## Open questions for the human
- Is the ambient `logger` still wanted in server-function context for anything that should deliberately escape the request event?


export const withDatabase = createMiddleware().server(({ next }) =>
next({ context: { database: database() } })
Expand Down
Loading