diff --git a/.changeset/rich-logging-banner.md b/.changeset/rich-logging-banner.md
new file mode 100644
index 00000000..235dd8b0
--- /dev/null
+++ b/.changeset/rich-logging-banner.md
@@ -0,0 +1,7 @@
+---
+'logixlysia': minor
+---
+
+- Add `formatLogOutput` with optional multi-line context tree; keep `formatLine` as a deprecated alias returning the main line only.
+- New config: `service`, `slowThreshold`, `verySlowThreshold`, `showContextTree`, `contextDepth`; default format includes `{icon}`, `{service}`, `{statusText}`, and `{speed}` tokens.
+- Startup banner shows URL and optional logixlysia package version in a boxed layout.
diff --git a/apps/docs/app/(home)/components/demo.tsx b/apps/docs/app/(home)/components/demo.tsx
index 249697dd..b4873fc0 100644
--- a/apps/docs/app/(home)/components/demo.tsx
+++ b/apps/docs/app/(home)/components/demo.tsx
@@ -2,29 +2,30 @@ import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock'
import { cn } from '@/lib/utils'
const code = `import { Elysia } from 'elysia'
-import logixlysia from 'logixlysia' // or import { logixlysia } from 'logixlysia'
+import logixlysia from 'logixlysia'
-const app = new Elysia({
- name: "Elysia with Logixlysia"
-})
+const app = new Elysia({ name: 'Elysia with Logixlysia' })
.use(
logixlysia({
config: {
+ service: 'api-server',
showStartupMessage: true,
- startupMessageFormat: 'simple',
+ startupMessageFormat: 'banner',
+ showContextTree: true,
+ contextDepth: 2,
+ slowThreshold: 500,
+ verySlowThreshold: 1000,
timestamp: {
translateTime: 'yyyy-mm-dd HH:MM:ss.SSS'
},
- logFilePath: './logs/example.log',
- ip: true,
- customLogFormat:
- '🦊 {now} {level} {duration} {method} {pathname} {status} {message} {ip}'
- }
- }))
- .get('/', () => {
+ ip: true
+ }
+ })
+ )
+ .get('/', () => {
return { message: 'Welcome to Basic Elysia with Logixlysia' }
})
-
+
app.listen(3000)`
export const Demo = () => (
diff --git a/apps/docs/app/(home)/components/playground/background.tsx b/apps/docs/app/(home)/components/playground/background.tsx
index 196c74ae..3c38444e 100644
--- a/apps/docs/app/(home)/components/playground/background.tsx
+++ b/apps/docs/app/(home)/components/playground/background.tsx
@@ -4,7 +4,14 @@ import Image from 'next/image'
import backgroundImage from './background.png'
export const Background = () => (
-
-
+
+
)
diff --git a/apps/docs/app/(home)/components/playground/index.tsx b/apps/docs/app/(home)/components/playground/index.tsx
index 0e3b389d..46df931a 100644
--- a/apps/docs/app/(home)/components/playground/index.tsx
+++ b/apps/docs/app/(home)/components/playground/index.tsx
@@ -4,27 +4,17 @@ import { useEffect, useMemo, useState } from 'react'
import { cn } from '@/lib/utils'
import { Background } from './background'
-const logLevel = {
- INFO: {
- color: 'text-muted bg-green-600'
- },
- WARNING: {
- color: 'text-muted bg-yellow-600'
- },
- ERROR: {
- color: 'text-muted bg-red-600'
- }
-}
+const METHOD_PAD = 7
const httpMethod = {
GET: {
color: 'text-green-500'
},
POST: {
- color: 'text-yellow-500'
+ color: 'text-blue-500'
},
PUT: {
- color: 'text-blue-500'
+ color: 'text-yellow-500'
},
PATCH: {
color: 'text-purple-500'
@@ -33,14 +23,14 @@ const httpMethod = {
color: 'text-red-500'
},
HEAD: {
- color: 'text-cyan-500'
+ color: 'text-green-400'
},
OPTIONS: {
- color: 'text-purple-500'
+ color: 'text-cyan-500'
}
-}
+} as const
-const statusCode = (status: number) => {
+const statusCodeClass = (status: number) => {
if (status >= 500) {
return 'text-red-500'
}
@@ -53,76 +43,162 @@ const statusCode = (status: number) => {
if (status >= 200) {
return 'text-green-500'
}
- return 'text-white'
+ return 'text-foreground'
}
-type LogType = keyof typeof logLevel
+type LogType = 'INFO' | 'WARNING' | 'ERROR'
type HttpMethod = keyof typeof httpMethod
+export interface ContextLine {
+ key: string
+ value: string
+}
+
export interface LogEntry {
- icon: string
timestamp: string
- duration: string
+ durationMs: number
method: HttpMethod
pathname: string
status: number
type: LogType
+ service: string
+ message: string
+ contextLines: ContextLine[]
}
+const SLOW_MS = 500
+const VERY_SLOW_MS = 1000
+
+const TIMESTAMP_PARTS = /\s+/
+
+/** Show time column like the terminal: `HH:mm:ss.SSS`. */
+const formatTimeColumn = (timestamp: string): string => {
+ const parts = timestamp.trim().split(TIMESTAMP_PARTS)
+ return parts.at(-1) ?? timestamp
+}
+
+const formatDurationMs = (ms: number): string => {
+ if (ms >= 1000) {
+ const sec = ms / 1000
+ if (sec >= 10) {
+ return `${Math.round(sec)}s`
+ }
+ const oneDecimal = sec.toFixed(1)
+ return oneDecimal.endsWith('.0') ? `${Math.round(sec)}s` : `${oneDecimal}s`
+ }
+ if (ms > 0 && ms < 1) {
+ return `${ms.toFixed(2)}ms`
+ }
+ return `${Math.round(ms)}ms`
+}
+
+const durationClass = (ms: number) => {
+ if (ms < SLOW_MS) {
+ return 'font-semibold text-green-500'
+ }
+ if (ms < VERY_SLOW_MS) {
+ return 'font-semibold text-yellow-500'
+ }
+ return 'font-bold text-red-500'
+}
+
+const foxChipClass = (type: LogType) =>
+ cn(
+ 'inline-flex shrink-0 items-center justify-center rounded-sm px-1 py-0.5 text-[10px] leading-none sm:text-xs',
+ type === 'INFO' && 'bg-green-600 text-black',
+ type === 'WARNING' && 'bg-yellow-500 text-black',
+ type === 'ERROR' && 'bg-red-600 text-black'
+ )
+
+const DEMO_SERVICE = 'api-server'
+
export const logs: LogEntry[] = [
{
- icon: '🦊',
- timestamp: '2025-04-13 15:00:19.225',
- duration: '1ms',
+ timestamp: '2025-04-13 18:12:18.699',
+ durationMs: 10,
method: 'GET',
pathname: '/',
status: 200,
- type: 'INFO'
+ type: 'INFO',
+ service: DEMO_SERVICE,
+ message: '',
+ contextLines: []
},
{
- icon: '🦊',
- timestamp: '2025-04-13 15:00:21.245',
- duration: '509μs',
- method: 'POST',
- pathname: '/items',
- status: 201,
- type: 'INFO'
+ timestamp: '2025-04-13 18:12:19.120',
+ durationMs: 0.14,
+ method: 'GET',
+ pathname: '/custom',
+ status: 200,
+ type: 'INFO',
+ service: DEMO_SERVICE,
+ message: 'Hello from custom logger',
+ contextLines: [
+ { key: 'feature', value: 'custom-route-log' },
+ { key: 'userId', value: '123' }
+ ]
},
{
- icon: '🦊',
- timestamp: '2025-04-13 15:00:22.225',
- duration: '900ns',
- method: 'PUT',
- pathname: '/items/123',
- status: 200,
- type: 'INFO'
+ timestamp: '2025-04-13 18:12:20.045',
+ durationMs: 0.12,
+ method: 'GET',
+ pathname: '/status/400',
+ status: 400,
+ type: 'WARNING',
+ service: DEMO_SERVICE,
+ message: '',
+ contextLines: []
},
{
- icon: '🦊',
- timestamp: '2025-04-13 15:00:23.30',
- duration: '1ms',
- method: 'DELETE',
- pathname: '/items/123',
- status: 200,
- type: 'INFO'
+ timestamp: '2025-04-13 18:12:21.089',
+ durationMs: 0.13,
+ method: 'GET',
+ pathname: '/status/404',
+ status: 404,
+ type: 'WARNING',
+ service: DEMO_SERVICE,
+ message: '',
+ contextLines: []
},
{
- icon: '🦊',
- timestamp: '2025-04-13 15:00:30.225',
- duration: '10s',
- method: 'PATCH',
- pathname: '/items/123',
+ timestamp: '2025-04-13 18:12:22.301',
+ durationMs: 0.45,
+ method: 'GET',
+ pathname: '/boom',
status: 500,
- type: 'ERROR'
+ type: 'ERROR',
+ service: DEMO_SERVICE,
+ message: 'Boom!',
+ contextLines: [
+ { key: 'feature', value: 'custom-route-log' },
+ { key: 'userId', value: '123' },
+ { key: 'error', value: 'Boom!' }
+ ]
},
{
- icon: '🦊',
- timestamp: '2025-04-13 15:00:31.225',
- duration: '1ms',
- method: 'HEAD',
+ timestamp: '2025-04-13 18:12:23.100',
+ durationMs: 12,
+ method: 'POST',
+ pathname: '/users',
+ status: 201,
+ type: 'INFO',
+ service: DEMO_SERVICE,
+ message: 'User signup',
+ contextLines: [
+ { key: 'email', value: 'ada@example.com' },
+ { key: 'feature', value: 'signup-flow' }
+ ]
+ },
+ {
+ timestamp: '2025-04-13 18:12:30.225',
+ durationMs: 1200,
+ method: 'PATCH',
pathname: '/items/123',
- status: 200,
- type: 'INFO'
+ status: 500,
+ type: 'ERROR',
+ service: DEMO_SERVICE,
+ message: 'Payment failed',
+ contextLines: [{ key: 'error', value: 'upstream timeout' }]
}
]
@@ -138,15 +214,33 @@ const PATHNAMES = [
'/auth/login',
'/auth/logout',
'/health',
- '/docs'
+ '/docs',
+ '/custom',
+ '/boom',
+ '/status/400',
+ '/status/404'
] as const
const STATUSES = [
200, 201, 204, 301, 304, 400, 401, 403, 404, 409, 429, 500, 502, 503
] as const
-// Deterministic, seeded PRNG (no bitwise — matches repo lint rules).
-// Park–Miller LCG: https://en.wikipedia.org/wiki/Lehmer_random_number_generator
+const INFO_MESSAGES = [
+ '',
+ 'Cache hit',
+ 'User signup',
+ 'Hello from custom logger',
+ 'Webhook accepted',
+ 'Session refreshed'
+] as const
+
+const ERROR_MESSAGES = [
+ 'Boom!',
+ 'Payment failed',
+ 'Upstream timeout',
+ 'Validation failed'
+] as const
+
const createRng = (seed: number) => {
const mod = 2_147_483_647
const mul = 16_807
@@ -195,18 +289,54 @@ const formatTimestamp = (d: Date) => {
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}.${ms}`
}
-const createRandomDuration = (rng: () => number) => {
- const bucket = rngInt(rng, 0, 3)
+const createRandomDurationMs = (rng: () => number) => {
+ const bucket = rngInt(rng, 0, 4)
if (bucket === 0) {
- return `${rngInt(rng, 50, 950)}ns`
+ return rng() * 0.99
}
if (bucket === 1) {
- return `${rngInt(rng, 1, 999)}μs`
+ return rngInt(rng, 1, 120)
}
if (bucket === 2) {
- return `${rngInt(rng, 1, 120)}ms`
+ return rngInt(rng, 120, 950)
+ }
+ if (bucket === 3) {
+ return rngInt(rng, 950, 2500)
+ }
+ return rngInt(rng, 2500, 12_000)
+}
+
+const buildRandomContext = (
+ rng: () => number,
+ type: LogType,
+ status: number
+): ContextLine[] => {
+ if (rng() < 0.32) {
+ return []
+ }
+
+ const lines: ContextLine[] = []
+
+ if (rng() > 0.45) {
+ lines.push({
+ key: 'requestId',
+ value: `req_${rngInt(rng, 10_000, 99_999)}`
+ })
+ }
+
+ if (type === 'ERROR' || status >= 500) {
+ if (rng() > 0.25) {
+ lines.push({ key: 'error', value: rngChoice(rng, ERROR_MESSAGES) })
+ }
+ } else if (rng() > 0.5) {
+ lines.push({ key: 'userId', value: String(rngInt(rng, 1, 99_999)) })
+ }
+
+ if (lines.length === 0 && rng() > 0.4) {
+ lines.push({ key: 'feature', value: 'demo-playground' })
}
- return `${rngInt(rng, 1, 12)}s`
+
+ return lines
}
const createRandomLog = (rng: () => number, now: number): LogEntry => {
@@ -223,15 +353,23 @@ const createRandomLog = (rng: () => number, now: number): LogEntry => {
const offsetMs = rngInt(rng, 0, 45_000)
const timestamp = formatTimestamp(new Date(now - offsetMs))
+ const durationMs = createRandomDurationMs(rng)
+
+ const message =
+ type === 'ERROR'
+ ? rngChoice(rng, ERROR_MESSAGES)
+ : rngChoice(rng, INFO_MESSAGES)
return {
- icon: '🦊',
timestamp,
- duration: createRandomDuration(rng),
+ durationMs,
method,
pathname,
status,
- type
+ type,
+ service: DEMO_SERVICE,
+ message,
+ contextLines: buildRandomContext(rng, type, status)
}
}
@@ -244,7 +382,7 @@ const createRepeatedRandomLogs = (seed: number) => {
)
}
-const Line = ({
+const MainLine = ({
children,
className
}: {
@@ -253,7 +391,7 @@ const Line = ({
}) => (
@@ -261,37 +399,99 @@ const Line = ({
)
-const Dim = ({ children }: { children: React.ReactNode }) => (
-
{children}
-)
+const ContextTree = ({ lines }: { lines: ContextLine[] }) => {
+ if (lines.length === 0) {
+ return null
+ }
-const Timestamp = ({ value }: { value: string }) => (
- <>
-
- {value.split(' ')[1] ?? value}
-
-
- {value}
-
- >
-)
+ const last = lines.length - 1
-const Level = ({ value }: { value: LogType }) => (
-
{value}
-)
+ return (
+
+ {lines.map((line, i) => {
+ const branch = i === last ? '└─' : '├─'
+ return (
+
+ {` ${branch} `}
+ {line.key}
+ {' '}
+ {line.value}
+
+ )
+ })}
+
+ )
+}
-const Method = ({ value }: { value: HttpMethod }) => (
-
{value}
-)
+const LogBlock = ({ log }: { log: LogEntry }) => {
+ const durationLabel = formatDurationMs(log.durationMs)
+ const showSlow = log.durationMs >= VERY_SLOW_MS
+ const methodPadded = log.method.toUpperCase().padEnd(METHOD_PAD, ' ')
+ const timeCol = formatTimeColumn(log.timestamp)
+
+ return (
+
+
+
+ {timeCol}
+
+
+ [{log.service}]{' '}
+
+
+ {' 🦊 '}
+
+
+ {methodPadded}
+
+
+ {log.pathname}
+
+
+ {log.status}
+
+
+ {durationLabel}
+
+ {log.message ? (
+
+ {log.message}
+
+ ) : null}
+ {showSlow ? (
+ ⚡ slow
+ ) : null}
+
+
+
+ )
+}
-const Status = ({ value }: { value: number }) => (
-
- {value}
-
+const TerminalCursor = () => (
+
+
+
)
const Output = () => {
- // Keep SSR/initial hydration stable; randomize only after mount.
const [seed, setSeed] = useState
(null)
useEffect(() => {
@@ -310,21 +510,10 @@ const Output = () => {
{repeatedLogs.flatMap((logList, repeatIndex) =>
logList.map((log, logIndex) => (
-
- {log.icon}
-
-
-
- {log.duration}
-
-
-
- {log.pathname}
-
-
-
+
))
)}
@@ -336,17 +525,36 @@ export const Playground = () => (
-
-
+
-
-
-
-
+
+
+
+
+
+ logixlysia — request logs
+
+
+
+
+
+
+
+
+
)
diff --git a/apps/docs/content/(docs)/api-reference.mdx b/apps/docs/content/(docs)/api-reference.mdx
index 47b6e252..a7e1150a 100644
--- a/apps/docs/content/(docs)/api-reference.mdx
+++ b/apps/docs/content/(docs)/api-reference.mdx
@@ -63,6 +63,11 @@ type Options = {
translateTime?: string
}
customLogFormat?: string
+ service?: string
+ slowThreshold?: number
+ verySlowThreshold?: number
+ showContextTree?: boolean
+ contextDepth?: number
transports?: Transport[]
useTransportsOnly?: boolean
disableInternalLogger?: boolean
diff --git a/apps/docs/content/(docs)/configuration.mdx b/apps/docs/content/(docs)/configuration.mdx
index c4a798b1..fc60ca18 100644
--- a/apps/docs/content/(docs)/configuration.mdx
+++ b/apps/docs/content/(docs)/configuration.mdx
@@ -99,17 +99,78 @@ Custom log message format using placeholders.
customLogFormat: '{now} {level} {duration}ms {method} {pathname} {status}'
```
+When `customLogFormat` is omitted, Logixlysia uses a built-in default that includes `{now}`, `{service}`, `{icon}`, `{method}`, `{pathname}`, `{status}`, `{duration}`, `{message}`, and `{speed}`.
+
Available placeholders:
- `{now}` - Current timestamp
- `{level}` - Log level
-- `{duration}` - Request duration
+- `{duration}` - Request duration (formatted, e.g. `12ms`, `1.5s`)
- `{method}` - HTTP method
- `{pathname}` - Request path
-- `{status}` - Response status
+- `{status}` - Response status code
+- `{statusText}` - HTTP status text from Node’s `http.STATUS_CODES` (e.g. `Not Found` for 404)
- `{message}` - Custom message
+- `{icon}` - Logixlysia fox (`🦊`); with colors enabled, a level-colored background chip around the emoji
+- `{speed}` - When duration is at or above `verySlowThreshold`, appends `⚡ slow` (yellow when colors are on)
+- `{service}` - Service label from the `service` config option, shown as `[name] ` (dim when colors are on); empty if unset
- `{ip}` - Client IP (from `x-forwarded-for` or `x-real-ip`; see `ip` option)
- `{epoch}` - Unix timestamp
+### `service`
+
+Service name used by the `{service}` placeholder (evlog-style `[my-app]` prefix).
+
+- **Type:** `string`
+- **Default:** `undefined` (no prefix)
+
+```ts
+service: 'my-api'
+```
+
+### `slowThreshold`
+
+Duration threshold (ms) for **green** duration text when colors are enabled. Between this value and `verySlowThreshold`, duration is **yellow**.
+
+- **Type:** `number`
+- **Default:** `500`
+
+```ts
+slowThreshold: 500
+```
+
+### `verySlowThreshold`
+
+Duration threshold (ms) at or above which duration is **red** (bold when colors are on) and the `{speed}` token adds `⚡ slow`.
+
+- **Type:** `number`
+- **Default:** `1000`
+
+```ts
+verySlowThreshold: 1000
+```
+
+### `showContextTree`
+
+When `true`, structured `context` passed to logger helpers is printed as **tree lines** under the main log line instead of being crammed into `{message}` on the same line.
+
+- **Type:** `boolean`
+- **Default:** `true`
+
+```ts
+showContextTree: true
+```
+
+### `contextDepth`
+
+How many levels of nested objects to expand in the context tree.
+
+- **Type:** `number`
+- **Default:** `1`
+
+```ts
+contextDepth: 2
+```
+
## Output Options
### `transports`
diff --git a/apps/docs/content/(docs)/preview.png b/apps/docs/content/(docs)/preview.png
index 7dbb7dbd..d7c61db7 100644
Binary files a/apps/docs/content/(docs)/preview.png and b/apps/docs/content/(docs)/preview.png differ
diff --git a/apps/docs/content/(docs)/usage.mdx b/apps/docs/content/(docs)/usage.mdx
index 81e4022f..5ae20a6c 100644
--- a/apps/docs/content/(docs)/usage.mdx
+++ b/apps/docs/content/(docs)/usage.mdx
@@ -53,12 +53,17 @@ Available placeholders:
| Placeholder | Description | Example |
| ----------- | ----------- | ----------- |
| `{now}` | Current timestamp | `2025-12-21 10:00:00` |
-| `{level}` | Log level (INFO, WARNING, ERROR) | `INFO` |
-| `{duration}` | Request duration in milliseconds | `100ms` |
+| `{level}` | Log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | `INFO` |
+| `{duration}` | Request duration (formatted) | `12ms`, `1.5s` |
| `{method}` | HTTP method | `GET` |
-| `{pathname}` | Request path | `/users` |
+| `{pathname}` | Request path (same as `{path}`) | `/users` |
| `{status}` | Response status code | `200` |
+| `{statusText}` | HTTP status text | `Not Found` |
| `{message}` | Custom message | `User profile accessed` |
+| `{icon}` | Logixlysia fox `🦊` (level-colored chip when colors + TTY) | `🦊` |
+| `{speed}` | Slow-request badge when duration ≥ `verySlowThreshold` | ` ⚡ slow` |
+| `{service}` | Service prefix from `config.service` | `[my-api] ` |
+| `{context}` | Context JSON on main line when tree is off / empty | `{"id":1}` |
| `{ip}` | Client IP address | `127.0.0.1` |
| `{epoch}` | Unix timestamp | `1734729600` |
diff --git a/apps/docs/content/features/formatting.mdx b/apps/docs/content/features/formatting.mdx
index a2d11604..39aebb7e 100644
--- a/apps/docs/content/features/formatting.mdx
+++ b/apps/docs/content/features/formatting.mdx
@@ -5,6 +5,8 @@ description: Create custom log message formats
Customize log message formats using placeholders to match your logging needs.
+If you omit `customLogFormat`, Logixlysia uses a built-in default that includes the fox `{icon}`, optional `{service}` prefix, colored method and duration, `{speed}` for slow requests, and other common fields.
+
## Basic Usage
```ts
@@ -20,12 +22,17 @@ logixlysia({
| Placeholder | Description |
|-------------|-------------|
| `{now}` | Current date and time |
-| `{level}` | Log level (INFO, WARNING, ERROR) |
-| `{duration}` | Request duration in milliseconds |
-| `{method}` | HTTP method |
-| `{pathname}` | Request path |
+| `{level}` | Log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`); with colors, level-colored background chip |
+| `{duration}` | Request duration (e.g. `12ms`, `1.5s`); with colors, green / yellow / red based on `slowThreshold` and `verySlowThreshold` |
+| `{method}` | HTTP method (padded in the default format) |
+| `{pathname}` | Request path (alias: `{path}`) |
| `{status}` | Response status code |
+| `{statusText}` | Status text from Node’s `http.STATUS_CODES` (e.g. `Not Found` for 404) |
| `{message}` | Custom message |
+| `{icon}` | Logixlysia fox `🦊`; plain emoji when `useColors` is off or output is not a TTY; with colors on a TTY, a **level-colored background** around the fox (green INFO, yellow WARNING, red ERROR, blue DEBUG) |
+| `{speed}` | When duration ≥ `verySlowThreshold`, appends ` ⚡ slow` (yellow with colors) |
+| `{service}` | From `config.service`, rendered as `[name] ` (dim with colors); empty if unset |
+| `{context}` | JSON string of `data.context` when the context tree is off or context is empty; omitted on the main line when the context tree is shown (see below) |
| `{ip}` | Client IP address (from `x-forwarded-for` or `x-real-ip`; empty when testing locally without these headers) |
| `{epoch}` | Unix timestamp |
@@ -42,17 +49,35 @@ Output:
GET /api/users 200
```
-### Detailed Format
+### Branded line with `{icon}` and `{service}`
+
+Use `{icon}` for the Logixlysia fox (level styling applies when colors and a TTY are available). Pair with `service` in config:
```ts
-customLogFormat: '🦊 {now} {level} {duration}ms {method} {pathname} {status} {ip}'
+logixlysia({
+ config: {
+ service: 'my-api',
+ customLogFormat: '{now} {service}{icon} {method} {pathname} {status} {duration} {message}{speed}'
+ }
+})
```
-Output:
+With `useColors: false` or non-TTY output, a line can look like:
+
```
-🦊 2025-04-13 15:00:19 INFO 123.45ms GET /api/users 200 192.168.1.1
+2025-04-13T15:00:19.123Z [my-api]🦊 GET /api/users 200 12ms User viewed profile
```
+On a color terminal, the fox appears inside a colored chip by level, and slow requests append `⚡ slow` via `{speed}` when duration ≥ `verySlowThreshold` (default `1000`).
+
+### Status text
+
+```ts
+customLogFormat: '{method} {pathname} {status} {statusText}'
+```
+
+Example values: `404 Not Found`, `500 Internal Server Error`.
+
### Timestamp Format
Configure timestamp format separately:
@@ -68,6 +93,32 @@ logixlysia({
})
```
+## Context tree
+
+When `showContextTree` is `true` (default), object `context` passed to `logger.info` / `warn` / `error` / `debug` is printed **under** the main line as tree branches, instead of being inlined into `{context}` on that line.
+
+- Each row is two spaces, `├─` or `└─`, the key (cyan when colors are on), two spaces, then the value.
+- For `ERROR` logs, an `error` row is appended when an error object is present (parsed message).
+- Set `contextDepth` (default `1`) to flatten nested objects into dotted keys (e.g. `user.id`) up to that depth.
+
+```ts
+logixlysia({
+ config: {
+ showContextTree: true,
+ contextDepth: 2
+ }
+})
+```
+
+Example (no ANSI colors), after a main line like `… GET /orders 500 3ms Checkout failed`:
+
+```
+ ├─ orderId ord_123
+ └─ error Card declined
+```
+
+Set `showContextTree: false` to disable the tree and put stringified context back on the main line via `{context}` when you include that token.
+
## Error Log Formatting
The `customLogFormat` applies to both regular access logs and error logs. When an error occurs (like validation errors or exceptions), the same formatting rules apply, ensuring consistent log output across all log levels.
diff --git a/apps/elysia/src/routers/index.ts b/apps/elysia/src/routers/index.ts
index 3231052e..824b222f 100644
--- a/apps/elysia/src/routers/index.ts
+++ b/apps/elysia/src/routers/index.ts
@@ -9,11 +9,13 @@ export const routers = new Elysia()
.use(
logixlysia({
config: {
+ service: 'elysia-demo',
timestamp: {
- translateTime: 'yyyy-mm-dd HH:MM:ss'
+ translateTime: 'HH:MM:ss.SSS'
},
- customLogFormat:
- '🦊 {now} {level} {duration} {method} {pathname} {status} {message} {ip} {context}',
+ slowThreshold: 500,
+ verySlowThreshold: 1000,
+ showContextTree: true,
logFilePath: './logs/example.log',
ip: true
}
diff --git a/packages/logixlysia/README.md b/packages/logixlysia/README.md
index a91a4e2a..9c3de0e1 100644
--- a/packages/logixlysia/README.md
+++ b/packages/logixlysia/README.md
@@ -22,15 +22,17 @@ const app = new Elysia({
.use(
logixlysia({
config: {
+ service: 'api-server',
showStartupMessage: true,
- startupMessageFormat: 'simple',
+ startupMessageFormat: 'banner',
+ showContextTree: true,
+ contextDepth: 2,
+ slowThreshold: 500,
+ verySlowThreshold: 1000,
timestamp: {
translateTime: 'yyyy-mm-dd HH:MM:ss.SSS'
},
- logFilePath: './logs/example.log',
- ip: true,
- customLogFormat:
- '🦊 {now} {level} {duration} {method} {pathname} {status} {message} {ip}'
+ ip: true
}
}))
.get('/', () => {
diff --git a/packages/logixlysia/__tests__/extensions/start-server.test.ts b/packages/logixlysia/__tests__/extensions/start-server.test.ts
index 0120be9d..83d8bc36 100644
--- a/packages/logixlysia/__tests__/extensions/start-server.test.ts
+++ b/packages/logixlysia/__tests__/extensions/start-server.test.ts
@@ -16,7 +16,9 @@ describe('startServer', () => {
expect(spies.log).toHaveBeenCalledTimes(1)
const output = spies.log.mock.calls[0]?.[0]
expect(String(output)).toContain('┌')
- expect(String(output)).toContain('🦊 Elysia is running at')
+ expect(String(output)).toContain('🦊')
+ expect(String(output)).toContain('http://localhost:3000')
+ expect(String(output)).toContain('logixlysia v')
restore()
})
diff --git a/packages/logixlysia/__tests__/logger/create-logger.test.ts b/packages/logixlysia/__tests__/logger/create-logger.test.ts
index 49427921..800ed924 100644
--- a/packages/logixlysia/__tests__/logger/create-logger.test.ts
+++ b/packages/logixlysia/__tests__/logger/create-logger.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, mock, test } from 'bun:test'
+import type pino from 'pino'
import type { Options, Pino } from '../../src/interfaces'
import { createLogger } from '../../src/logger'
import { spyConsole } from '../_helpers/console'
@@ -100,7 +101,7 @@ describe('createLogger', () => {
}
}
},
- fakePino
+ fakePino as unknown as typeof pino
)
expect(captured.options).toMatchObject({
@@ -125,7 +126,7 @@ describe('createLogger', () => {
}
}
},
- fakePino
+ fakePino as unknown as typeof pino
)
expect(captured.options).toMatchObject({
@@ -151,7 +152,7 @@ describe('createLogger', () => {
}
}
},
- fakePino
+ fakePino as unknown as typeof pino
)
expect(captured.options).toMatchObject({
@@ -179,7 +180,7 @@ describe('createLogger', () => {
}
}
},
- fakePino
+ fakePino as unknown as typeof pino
)
expect(captured.options).toMatchObject({
@@ -206,7 +207,7 @@ describe('createLogger', () => {
}
}
},
- fakePino
+ fakePino as unknown as typeof pino
)
expect(captured.options).toMatchObject({
@@ -236,7 +237,7 @@ describe('createLogger', () => {
}
}
},
- fakePino
+ fakePino as unknown as typeof pino
)
expect(captured.options).toMatchObject({
diff --git a/packages/logixlysia/__tests__/logger/format-output.test.ts b/packages/logixlysia/__tests__/logger/format-output.test.ts
new file mode 100644
index 00000000..1cc2bcdb
--- /dev/null
+++ b/packages/logixlysia/__tests__/logger/format-output.test.ts
@@ -0,0 +1,184 @@
+import { describe, expect, test } from 'bun:test'
+import type { Options } from '../../src/interfaces'
+import {
+ buildContextTreeLines,
+ formatDuration,
+ formatLogOutput
+} from '../../src/logger/create-logger'
+import { createMockRequest } from '../_helpers/request'
+
+describe('formatDuration', () => {
+ test('formats sub-second requests as ms', () => {
+ expect(formatDuration(0)).toBe('0ms')
+ expect(formatDuration(12)).toBe('12ms')
+ expect(formatDuration(999)).toBe('999ms')
+ })
+
+ test('formats 1s+ with s suffix', () => {
+ expect(formatDuration(1000)).toBe('1s')
+ expect(formatDuration(1500)).toBe('1.5s')
+ expect(formatDuration(10_500)).toBe('11s')
+ })
+
+ test('formats fractional ms under 1ms', () => {
+ expect(formatDuration(0.34)).toBe('0.34ms')
+ })
+})
+
+describe('formatLogOutput', () => {
+ const baseOptions = (overrides?: Options): Options => ({
+ config: {
+ useColors: false,
+ ...overrides?.config
+ }
+ })
+
+ test('includes path, status, and icon in main line', () => {
+ const request = createMockRequest('http://localhost/api/hello')
+ const store = { beforeTime: BigInt(0) }
+ const out = formatLogOutput({
+ level: 'INFO',
+ request,
+ data: { status: 200 },
+ store,
+ options: baseOptions()
+ })
+
+ expect(out.main).toContain('/api/hello')
+ expect(out.main).toContain('200')
+ expect(out.main).toContain('GET')
+ expect(out.main).toContain('🦊')
+ expect(out.contextLines).toEqual([])
+ })
+
+ test('appends context tree when context object is non-empty', () => {
+ const request = createMockRequest('http://localhost/x')
+ const store = { beforeTime: BigInt(0) }
+ const out = formatLogOutput({
+ level: 'INFO',
+ request,
+ data: {
+ status: 200,
+ context: { userId: 42, feature: 'test' }
+ },
+ store,
+ options: baseOptions()
+ })
+
+ expect(out.contextLines.length).toBe(2)
+ expect(out.contextLines[0]).toContain('├─')
+ expect(out.contextLines[0]).toContain('userId')
+ expect(out.contextLines[0]).toContain('42')
+ expect(out.contextLines[1]).toContain('└─')
+ expect(out.contextLines[1]).toContain('feature')
+ expect(out.main).not.toContain('"userId"')
+ })
+
+ test('inlines JSON context when showContextTree is false', () => {
+ const request = createMockRequest('http://localhost/x')
+ const store = { beforeTime: BigInt(0) }
+ const out = formatLogOutput({
+ level: 'INFO',
+ request,
+ data: {
+ status: 200,
+ context: { a: 1 }
+ },
+ store,
+ options: baseOptions({
+ config: {
+ useColors: false,
+ showContextTree: false,
+ customLogFormat: '{pathname} {context}'
+ }
+ })
+ })
+
+ expect(out.contextLines).toEqual([])
+ expect(out.main).toContain('{"a":1}')
+ })
+
+ test('includes service token when configured', () => {
+ const request = createMockRequest('http://localhost/')
+ const store = { beforeTime: BigInt(0) }
+ const out = formatLogOutput({
+ level: 'INFO',
+ request,
+ data: { status: 200 },
+ store,
+ options: baseOptions({
+ config: { useColors: false, service: 'my-api' }
+ })
+ })
+
+ expect(out.main).toContain('[my-api]')
+ })
+
+ test('includes statusText token in custom format', () => {
+ const request = createMockRequest('http://localhost/not-found')
+ const store = { beforeTime: BigInt(0) }
+ const out = formatLogOutput({
+ level: 'INFO',
+ request,
+ data: { status: 404 },
+ store,
+ options: baseOptions({
+ config: {
+ useColors: false,
+ customLogFormat: '{status} {statusText} {pathname}'
+ }
+ })
+ })
+
+ expect(out.main).toContain('404')
+ expect(out.main).toContain('Not Found')
+ expect(out.main).toContain('/not-found')
+ })
+
+ test('speed token appears when duration exceeds verySlowThreshold', () => {
+ const request = createMockRequest('http://localhost/slow')
+ const store = {
+ beforeTime: process.hrtime.bigint() - BigInt(1_200_000_000)
+ }
+ const out = formatLogOutput({
+ level: 'INFO',
+ request,
+ data: { status: 200 },
+ store,
+ options: baseOptions({
+ config: {
+ useColors: false,
+ verySlowThreshold: 1000,
+ slowThreshold: 500
+ }
+ })
+ })
+
+ expect(out.main).toContain('⚡ slow')
+ })
+})
+
+describe('buildContextTreeLines', () => {
+ test('adds error line for ERROR level with error field', () => {
+ const lines = buildContextTreeLines(
+ 'ERROR',
+ { error: new Error('nope') },
+ { config: { useColors: false } }
+ )
+
+ expect(lines.length).toBe(1)
+ expect(lines[0]).toContain('error')
+ expect(lines[0]).toContain('nope')
+ })
+
+ test('expands nested context when contextDepth > 1', () => {
+ const lines = buildContextTreeLines(
+ 'INFO',
+ { context: { user: { id: 7, name: 'a' } } },
+ { config: { useColors: false, contextDepth: 2 } }
+ )
+
+ expect(lines.some(l => l.includes('user.id'))).toBe(true)
+ expect(lines.some(l => l.includes('user.name'))).toBe(true)
+ })
+})
diff --git a/packages/logixlysia/src/extensions/banner.ts b/packages/logixlysia/src/extensions/banner.ts
index 2418c401..1cc8eecb 100644
--- a/packages/logixlysia/src/extensions/banner.ts
+++ b/packages/logixlysia/src/extensions/banner.ts
@@ -9,6 +9,15 @@ const elysiaPkg: { version?: string } = (() => {
}
})()
+const logixlysiaPkg: { version?: string } = (() => {
+ try {
+ const require = createRequire(import.meta.url)
+ return require('../../package.json') as { version?: string }
+ } catch {
+ return {}
+ }
+})()
+
const centerText = (text: string, width: number): string => {
if (text.length >= width) {
return text.slice(0, width)
@@ -19,18 +28,65 @@ const centerText = (text: string, width: number): string => {
return `${' '.repeat(left)}${text}${' '.repeat(right)}`
}
-export const renderBanner = (message: string): string => {
+type RowSpec =
+ | { kind: 'empty' }
+ | { kind: 'center'; text: string }
+ | { kind: 'left'; text: string }
+
+/**
+ * Box banner: Elysia version (centered), URL line, optional logixlysia version line.
+ */
+export const renderBanner = (
+ urlDisplayLine: string,
+ logixlysiaLine: string | null
+): string => {
const version = elysiaPkg.version
const versionLine = version ? `Elysia v${version}` : 'Elysia'
- const contentWidth = Math.max(message.length, versionLine.length)
- const innerWidth = contentWidth + 4 // 2 spaces padding on both sides
+
+ const rows: RowSpec[] = [
+ { kind: 'empty' },
+ { kind: 'center', text: versionLine },
+ { kind: 'empty' },
+ { kind: 'left', text: urlDisplayLine }
+ ]
+ if (logixlysiaLine) {
+ rows.push({ kind: 'left', text: logixlysiaLine })
+ }
+ rows.push({ kind: 'empty' })
+
+ const contentWidth = Math.max(
+ versionLine.length,
+ urlDisplayLine.length,
+ ...(logixlysiaLine ? [logixlysiaLine.length] : [0])
+ )
+ const innerWidth = contentWidth + 4
const top = `┌${'─'.repeat(innerWidth)}┐`
const bot = `└${'─'.repeat(innerWidth)}┘`
- const empty = `│${' '.repeat(innerWidth)}│`
+ const emptyRow = `│${' '.repeat(innerWidth)}│`
- const versionRow = `│${centerText(versionLine, innerWidth)}│`
- const messageRow = `│ ${message}${' '.repeat(Math.max(0, innerWidth - message.length - 4))} │`
+ const out: string[] = [top]
+ for (const row of rows) {
+ if (row.kind === 'empty') {
+ out.push(emptyRow)
+ continue
+ }
+ if (row.kind === 'center') {
+ out.push(`│${centerText(row.text, innerWidth)}│`)
+ continue
+ }
+ const text = row.text
+ const padding = Math.max(0, innerWidth - text.length - 4)
+ out.push(`│ ${text}${' '.repeat(padding)} │`)
+ }
+ out.push(bot)
+ return out.join('\n')
+}
- return [top, empty, versionRow, empty, messageRow, empty, bot].join('\n')
+export const getLogixlysiaVersionLine = (): string | null => {
+ const v = logixlysiaPkg.version
+ if (!v) {
+ return null
+ }
+ return ` logixlysia v${v}`
}
diff --git a/packages/logixlysia/src/extensions/index.ts b/packages/logixlysia/src/extensions/index.ts
index 78317b71..058de083 100644
--- a/packages/logixlysia/src/extensions/index.ts
+++ b/packages/logixlysia/src/extensions/index.ts
@@ -1,5 +1,5 @@
import type { Options } from '../interfaces'
-import { renderBanner } from './banner'
+import { getLogixlysiaVersionLine, renderBanner } from './banner'
export const startServer = (
server: { port?: number; hostname?: string; protocol?: string | null },
@@ -17,6 +17,7 @@ export const startServer = (
const url = `${protocol}://${hostname}:${port}`
const message = `🦊 Elysia is running at ${url}`
+ const urlDisplayLine = `🦊 ${url}`
const format = options.config?.startupMessageFormat ?? 'banner'
if (format === 'simple') {
@@ -24,5 +25,5 @@ export const startServer = (
return
}
- console.log(renderBanner(message))
+ console.log(renderBanner(urlDisplayLine, getLogixlysiaVersionLine()))
}
diff --git a/packages/logixlysia/src/interfaces.ts b/packages/logixlysia/src/interfaces.ts
index 6ab48ed2..26ed96f2 100644
--- a/packages/logixlysia/src/interfaces.ts
+++ b/packages/logixlysia/src/interfaces.ts
@@ -74,6 +74,17 @@ export interface Options {
}
customLogFormat?: string
+ /** Service name shown in `{service}` token (e.g. evlog-style `[my-app]`). */
+ service?: string
+ /** Duration (ms) below this uses green; default 500. */
+ slowThreshold?: number
+ /** Duration (ms) at or above this uses red + `{speed}` badge; default 1000. */
+ verySlowThreshold?: number
+ /** Render `data.context` as tree lines under the main log line; default true. */
+ showContextTree?: boolean
+ /** How many object nesting levels to expand in the context tree; default 1. */
+ contextDepth?: number
+
// Filtering
logFilter?: LogFilter
diff --git a/packages/logixlysia/src/logger/create-logger.ts b/packages/logixlysia/src/logger/create-logger.ts
index 80479566..add84f4e 100644
--- a/packages/logixlysia/src/logger/create-logger.ts
+++ b/packages/logixlysia/src/logger/create-logger.ts
@@ -1,3 +1,4 @@
+import { STATUS_CODES } from 'node:http'
import chalk from 'chalk'
import { getStatusCode } from '../helpers/status'
import type {
@@ -7,10 +8,23 @@ import type {
RequestInfo,
StoreData
} from '../interfaces'
+import { parseError } from '../utils/error'
const pad2 = (value: number): string => String(value).padStart(2, '0')
const pad3 = (value: number): string => String(value).padStart(3, '0')
+const DEFAULT_SLOW_MS = 500
+const DEFAULT_VERY_SLOW_MS = 1000
+const METHOD_PAD = 7
+
+const DEFAULT_LOG_FORMAT =
+ '{now} {service}{icon} {method} {pathname} {status} {duration} {message}{speed}'
+
+export interface FormattedLogOutput {
+ main: string
+ contextLines: string[]
+}
+
const shouldUseColors = (options: Options): boolean => {
const config = options.config
const enabledByConfig = config?.useColors ?? true
@@ -52,6 +66,84 @@ const getIp = (request: RequestInfo): string => {
return request.headers.get('x-real-ip') ?? ''
}
+export const formatDuration = (ms: number): string => {
+ if (ms >= 1000) {
+ const sec = ms / 1000
+ if (sec >= 10) {
+ return `${Math.round(sec)}s`
+ }
+ const oneDecimal = sec.toFixed(1)
+ return oneDecimal.endsWith('.0') ? `${Math.round(sec)}s` : `${oneDecimal}s`
+ }
+ if (ms > 0 && ms < 1) {
+ return `${ms.toFixed(2)}ms`
+ }
+ return `${Math.round(ms)}ms`
+}
+
+const getSlowThresholds = (
+ options: Options
+): { slow: number; verySlow: number } => {
+ const config = options.config
+ return {
+ slow: config?.slowThreshold ?? DEFAULT_SLOW_MS,
+ verySlow: config?.verySlowThreshold ?? DEFAULT_VERY_SLOW_MS
+ }
+}
+
+const colorDurationText = (
+ ms: number,
+ useColors: boolean,
+ options: Options
+): { text: string; isVerySlow: boolean } => {
+ const raw = formatDuration(ms)
+ const { slow, verySlow } = getSlowThresholds(options)
+ const isVerySlow = ms >= verySlow
+
+ if (!useColors) {
+ return { text: raw, isVerySlow }
+ }
+
+ let colored = raw
+ if (ms < slow) {
+ colored = chalk.green(raw)
+ } else if (ms < verySlow) {
+ colored = chalk.yellow(raw)
+ } else {
+ colored = chalk.red.bold(raw)
+ }
+
+ return { text: colored, isVerySlow }
+}
+
+const getSpeedToken = (isVerySlow: boolean, useColors: boolean): string => {
+ if (!isVerySlow) {
+ return ''
+ }
+ const badge = '⚡ slow'
+ if (!useColors) {
+ return ` ${badge}`
+ }
+ return ` ${chalk.yellow(badge)}`
+}
+
+/** Logixlysia brand: fox emoji with level-colored background when colors are enabled. */
+const getLevelIcon = (level: LogLevel, useColors: boolean): string => {
+ if (!useColors) {
+ return '🦊'
+ }
+ if (level === 'ERROR') {
+ return chalk.bgRed.black(' 🦊 ')
+ }
+ if (level === 'WARNING') {
+ return chalk.bgYellow.black(' 🦊 ')
+ }
+ if (level === 'DEBUG') {
+ return chalk.bgBlue.black(' 🦊 ')
+ }
+ return chalk.bgGreen.black(' 🦊 ')
+}
+
const getColoredLevel = (level: LogLevel, useColors: boolean): string => {
if (!useColors) {
return level
@@ -132,20 +224,12 @@ const getColoredStatus = (status: string, useColors: boolean): string => {
return chalk.gray(status)
}
-const getColoredDuration = (duration: string, useColors: boolean): string => {
- if (!useColors) {
- return duration
- }
-
- return chalk.gray(duration)
-}
-
const getColoredTimestamp = (timestamp: string, useColors: boolean): string => {
if (!useColors) {
return timestamp
}
- return chalk.bgHex('#FFA500').black(timestamp)
+ return chalk.gray(timestamp)
}
const getColoredPathname = (pathname: string, useColors: boolean): string => {
@@ -156,6 +240,148 @@ const getColoredPathname = (pathname: string, useColors: boolean): string => {
return chalk.whiteBright(pathname)
}
+const getStatusText = (statusCode: number): string => {
+ const text = STATUS_CODES[statusCode]
+ return text ?? ''
+}
+
+const getServiceToken = (options: Options, useColors: boolean): string => {
+ const name = options.config?.service?.trim()
+ if (!name) {
+ return ''
+ }
+ const bracketed = `[${name}]`
+ if (!useColors) {
+ return `${bracketed} `
+ }
+ return `${chalk.dim(bracketed)} `
+}
+
+const stringifyTreeValue = (value: unknown): string => {
+ if (value === null) {
+ return 'null'
+ }
+ if (value === undefined) {
+ return 'undefined'
+ }
+ if (typeof value === 'string') {
+ return value
+ }
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return String(value)
+ }
+ if (value instanceof Error) {
+ return value.message
+ }
+ try {
+ return JSON.stringify(value)
+ } catch {
+ return String(value)
+ }
+}
+
+/** Nested objects to expand in the context tree (excludes Arrays, Error, Date). */
+const isExpandableObject = (value: unknown): value is Record =>
+ value !== null &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ !(value instanceof Error) &&
+ !(value instanceof Date)
+
+const collectContextEntries = (
+ obj: Record,
+ prefix: string,
+ depthRemaining: number
+): [string, string][] => {
+ const out: [string, string][] = []
+ for (const [k, v] of Object.entries(obj)) {
+ const key = prefix ? `${prefix}.${k}` : k
+ const expandable = isExpandableObject(v) && depthRemaining > 1
+
+ if (expandable) {
+ out.push(...collectContextEntries(v, key, depthRemaining - 1))
+ } else {
+ out.push([key, stringifyTreeValue(v)])
+ }
+ }
+ return out
+}
+
+const formatEntriesToTreeLines = (
+ entries: [string, string][],
+ useColors: boolean
+): string[] => {
+ if (entries.length === 0) {
+ return []
+ }
+
+ const lines: string[] = []
+ const last = entries.length - 1
+ for (let i = 0; i < entries.length; i++) {
+ const branch = i === last ? '└─' : '├─'
+ const pair = entries[i]
+ if (!pair) {
+ continue
+ }
+ const [k, v] = pair
+ const keyPart = useColors ? chalk.cyan(k) : k
+ const valPart = useColors ? chalk.white(v) : v
+ lines.push(` ${branch} ${keyPart} ${valPart}`)
+ }
+ return lines
+}
+
+export const renderContextTreeLines = (
+ ctx: Record,
+ options: Options,
+ useColors: boolean
+): string[] => {
+ const depth = options.config?.contextDepth ?? 1
+ if (depth < 1) {
+ return []
+ }
+
+ const entries = collectContextEntries(ctx, '', depth)
+ return formatEntriesToTreeLines(entries, useColors)
+}
+
+export const buildContextTreeLines = (
+ level: LogLevel,
+ data: Record,
+ options: Options
+): string[] => {
+ if (options.config?.showContextTree === false) {
+ return []
+ }
+
+ const useColors = shouldUseColors(options)
+ const depth = options.config?.contextDepth ?? 1
+
+ const entries: [string, string][] = []
+
+ const ctx = data.context
+ if (
+ ctx &&
+ typeof ctx === 'object' &&
+ !Array.isArray(ctx) &&
+ Object.keys(ctx as object).length > 0 &&
+ depth >= 1
+ ) {
+ entries.push(
+ ...collectContextEntries(ctx as Record, '', depth)
+ )
+ }
+
+ if (level === 'ERROR' && 'error' in data && data.error !== undefined) {
+ const msg = parseError(data.error)
+ if (msg) {
+ entries.push(['error', msg])
+ }
+ }
+
+ return formatEntriesToTreeLines(entries, useColors)
+}
+
const getContextString = (value: unknown): string => {
if (typeof value === 'object' && value !== null) {
return JSON.stringify(value)
@@ -164,7 +390,7 @@ const getContextString = (value: unknown): string => {
return ''
}
-export const formatLine = ({
+export const formatLogOutput = ({
level,
request,
data,
@@ -176,12 +402,10 @@ export const formatLine = ({
data: Record
store: StoreData
options: Options
-}): string => {
+}): FormattedLogOutput => {
const config = options.config
const useColors = shouldUseColors(options)
- const format =
- config?.customLogFormat ??
- '🦊 {now} {level} {duration} {method} {pathname} {status} {message} {ip} {context}'
+ const format = config?.customLogFormat ?? DEFAULT_LOG_FORMAT
const now = new Date()
const epoch = String(now.getTime())
@@ -202,30 +426,73 @@ export const formatLine = ({
: getStatusCode(statusValue)
const status = String(statusCode)
const ip = config?.ip === true ? getIp(request) : ''
- const ctxString = getContextString(data.context)
+
+ const showTree = config?.showContextTree !== false
+ const ctxString =
+ showTree &&
+ data.context &&
+ typeof data.context === 'object' &&
+ !Array.isArray(data.context) &&
+ Object.keys(data.context as object).length > 0
+ ? ''
+ : getContextString(data.context)
+
const coloredLevel = getColoredLevel(level, useColors)
- const coloredMethod = getColoredMethod(request.method, useColors)
+ const methodPadded = request.method.toUpperCase().padEnd(METHOD_PAD)
+ const coloredMethod = getColoredMethod(methodPadded, useColors)
const coloredPathname = getColoredPathname(pathname, useColors)
const coloredStatus = getColoredStatus(status, useColors)
- const coloredDuration = getColoredDuration(
- `${durationMs.toFixed(2)}ms`,
- useColors
+ const { text: coloredDuration, isVerySlow } = colorDurationText(
+ durationMs,
+ useColors,
+ options
)
+ const speedToken = getSpeedToken(isVerySlow, useColors)
+ const icon = getLevelIcon(level, useColors)
+ const statusText = getStatusText(statusCode)
+ const serviceToken = getServiceToken(options, useColors)
- return format
+ const main = format
.replaceAll('{now}', timestamp)
.replaceAll('{epoch}', epoch)
.replaceAll('{level}', coloredLevel)
+ .replaceAll('{icon}', icon)
.replaceAll('{duration}', coloredDuration)
.replaceAll('{method}', coloredMethod)
.replaceAll('{pathname}', coloredPathname)
.replaceAll('{path}', coloredPathname)
.replaceAll('{status}', coloredStatus)
+ .replaceAll('{statusText}', statusText)
.replaceAll('{message}', message)
.replaceAll('{ip}', ip)
.replaceAll('{context}', ctxString)
+ .replaceAll('{service}', serviceToken)
+ .replaceAll('{speed}', speedToken)
+
+ const contextLines = buildContextTreeLines(level, data, options)
+
+ return { main, contextLines }
}
+/** @deprecated Prefer {@link formatLogOutput} for multi-line context trees. Returns the main line only. */
+export const formatLine = (input: {
+ level: LogLevel
+ request: RequestInfo
+ data: Record
+ store: StoreData
+ options: Options
+}): string =>
+ formatLogOutput({
+ ...input,
+ options: {
+ ...input.options,
+ config: {
+ ...(input.options.config ?? {}),
+ showContextTree: false
+ }
+ }
+ }).main
+
export const logWithPino = (
logger: Pino,
level: LogLevel,
diff --git a/packages/logixlysia/src/logger/handle-http-error.ts b/packages/logixlysia/src/logger/handle-http-error.ts
index 1e3b0cd1..61d61eb5 100644
--- a/packages/logixlysia/src/logger/handle-http-error.ts
+++ b/packages/logixlysia/src/logger/handle-http-error.ts
@@ -2,7 +2,7 @@ import type { LogLevel, Options, RequestInfo, StoreData } from '../interfaces'
import { logToTransports } from '../output'
import { logToFile } from '../output/file'
import { parseError } from '../utils/error'
-import { formatLine } from './create-logger'
+import { formatLogOutput } from './create-logger'
const isErrorWithStatus = (
value: unknown
@@ -56,6 +56,14 @@ export const handleHttpError = (
return
}
- const formattedMessage = formatLine({ level, request, data, store, options })
+ const { main, contextLines } = formatLogOutput({
+ level,
+ request,
+ data,
+ store,
+ options
+ })
+ const formattedMessage =
+ contextLines.length > 0 ? `${main}\n${contextLines.join('\n')}` : main
console.error(formattedMessage)
}
diff --git a/packages/logixlysia/src/logger/index.ts b/packages/logixlysia/src/logger/index.ts
index e29015fa..797c4384 100644
--- a/packages/logixlysia/src/logger/index.ts
+++ b/packages/logixlysia/src/logger/index.ts
@@ -10,7 +10,7 @@ import type {
} from '../interfaces'
import { logToTransports } from '../output'
import { logToFile } from '../output/file'
-import { formatLine } from './create-logger'
+import { formatLogOutput } from './create-logger'
import { handleHttpError } from './handle-http-error'
export const createLogger = (
@@ -99,7 +99,15 @@ export const createLogger = (
return
}
- const message = formatLine({ level, request, data, store, options })
+ const { main, contextLines } = formatLogOutput({
+ level,
+ request,
+ data,
+ store,
+ options
+ })
+ const message =
+ contextLines.length > 0 ? `${main}\n${contextLines.join('\n')}` : main
switch (level) {
case 'DEBUG': {