diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 1b6a356..57eee9b 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -1,9 +1,29 @@ - \ No newline at end of file + diff --git a/README.md b/README.md index 728f2aa..5efda25 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ Start logging with `console.error`, `console.warn`, `console.log`, # App Server Support -Preconfigured support for ExpressJS and Fresh V1 provides the following: +Preconfigured support for ExpressJS, Fresh V1, Hono, and SolidStart provides the +following: - Metrics (Request and Logger) - Log Levels @@ -73,6 +74,33 @@ export default defineConfig({ }); ``` +## Hono Configuration + +```typescript +import { Hono } from "hono"; +import { honoLoggerMiddleware, initLogger } from "@daringway/logger"; + +initLogger(); + +const app = new Hono(); +app.use("*", honoLoggerMiddleware()); +``` + +## SolidStart Configuration + +In a SolidStart middleware file: + +```typescript +import { createMiddleware } from "@solidjs/start/middleware"; +import { initLogger, solidStartLoggerMiddleware } from "@daringway/logger"; + +initLogger(); + +export default createMiddleware({ + onRequest: solidStartLoggerMiddleware(), +}); +``` + ## Request Context Information ```typescript @@ -110,7 +138,8 @@ WebStorm testing. Default: false LOG_PRETTY: boolean Enable pretty printing of logs for development. Use for CLI testing. Default: false -LOG_WITH_CONSOLE: boolean Enable logging to console. Default: false, writes to STDOUT +LOG_WITH_CONSOLE: boolean Enable logging to console. Default: false, writes to +STDOUT # Log Levels Explained diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..623945a --- /dev/null +++ b/agents.md @@ -0,0 +1,33 @@ +# Agents Reference Table + +Use this file as the quick index. Follow each `Details` link for full behavior, +caveats, and source mapping. + +| Area | Setting | Default | Override Path | Details | +| ------------- | ---------------------------------------------- | ----------------------: | ---------------------------------------------- | ---------------------------------------------------------------------------- | +| Logger | `logLevel` | `"log"` | `initLogger`, `LOG_LEVEL` | [details](docs/settings.md#loglevel) | +| Logger | `logSecondsBetweenMetrics` | `500` | `initLogger`, `LOG_SECONDS_BETWEEN_METRICS` | [details](docs/settings.md#logsecondsbetweenmetrics) | +| Logger | `logPriorityThresholdBytes` | `1048576` | `initLogger`, `LOG_PRIORITY_THRESHOLD_BYTES` | [details](docs/settings.md#logprioritythresholdbytes) | +| Logger | `logMeta` | `null` | `initLogger` | [details](docs/settings.md#logmeta) | +| Logger | `logObjects` | `false` | `initLogger`, `LOG_OBJECTS` | [details](docs/settings.md#logobjects) | +| Logger | `logPretty` | `false` | `initLogger`, `LOG_PRETTY` | [details](docs/settings.md#logpretty) | +| Logger | `logWithConsole` | `false` | `initLogger`, `LOG_WITH_CONSOLE` | [details](docs/settings.md#logwithconsole) | +| Logger | `silentInit` | `false` | `initLogger` | [details](docs/settings.md#silentinit) | +| Env Var | `LOG_LEVEL` | n/a | environment | [details](docs/settings.md#log_level) | +| Env Var | `LOG_SECONDS_BETWEEN_METRICS` | n/a | environment | [details](docs/settings.md#log_seconds_between_metrics) | +| Env Var | `LOG_PRIORITY_THRESHOLD_BYTES` | n/a | environment | [details](docs/settings.md#log_priority_threshold_bytes) | +| Env Var | `LOG_OBJECTS` | n/a | environment | [details](docs/settings.md#log_objects) | +| Env Var | `LOG_PRETTY` | n/a | environment | [details](docs/settings.md#log_pretty) | +| Env Var | `LOG_WITH_CONSOLE` | n/a | environment | [details](docs/settings.md#log_with_console) | +| Express | `doNotLogURLs` | unset | `expressLoggerMiddleware({ doNotLogURLs })` | [details](docs/settings.md#donotlogurls-express) | +| Fresh | `setCookies` | unset | `freshV1LoggerPlugin({ setCookies })` | [details](docs/settings.md#setcookies-fresh) | +| Fresh | `doNotLogURLs` | unset | `freshV1LoggerPlugin({ doNotLogURLs })` | [details](docs/settings.md#donotlogurls-fresh) | +| Hono | `doNotLogURLs` | unset | `honoLoggerMiddleware({ doNotLogURLs })` | [details](docs/settings.md#donotlogurls-hono) | +| SolidStart | `doNotLogURLs` | unset | `solidStartLoggerMiddleware({ doNotLogURLs })` | [details](docs/settings.md#donotlogurls-solidstart) | +| Context Input | `x-request-id` | generated | request header/cookie | [details](docs/settings.md#x-request-id) | +| Context Input | `x-trace-path` | derived | request header | [details](docs/settings.md#x-trace-path) | +| Context Input | `x-correlation-id` / `correlationId` | `"unknown"` | request header/cookie | [details](docs/settings.md#x-correlation-id--correlationid-cookie) | +| Context Input | `authorization` / `session` | `"unknown"` | request header/cookie | [details](docs/settings.md#authorization--session-cookie) | +| Context Input | `x-application-name` / `applicationName` | `"unknown"`/`"postman"` | request header/cookie | [details](docs/settings.md#x-application-name--applicationname-cookie) | +| Context Input | `x-application-version` / `applicationVersion` | `"unknown"` | request header/cookie | [details](docs/settings.md#x-application-version--applicationversion-cookie) | +| Context Input | `user-agent` | `"unknown"` | request header | [details](docs/settings.md#user-agent) | diff --git a/deno.json b/deno.json index a86a493..eb08be4 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@daringway/logger", - "version": "0.0.8", + "version": "0.0.9", "license": "MIT", "description": "A high-performance, drop-in replacement for console.log() that outputs structured JSON logs and metrics to STDOUT. Built for both Deno and Node.js, it delivers blazing-fast, non-blocking logging with optional request context, log levels, and ExpressJS/Fresh V1 integration—making it ideal for production apps that need speed, consistency, and easy observability.", "exports": "./mod.ts", diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 0000000..7107cac --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,229 @@ +# Settings Reference + +This document is the detailed companion for [`agents.md`](../agents.md). + +## Configuration Precedence + +For logger config (`initLogger`), values are resolved in this order: + +1. Environment variable (if available for that setting) +2. `initLogger({...})` argument +3. Existing in-memory `logConfig` +4. Built-in defaults + +## Logger Settings (`initLogger`) + +### logLevel + +- Scope: logger +- Type: `"metrics" | "error" | "warn" | "info" | "log" | "debug" | "trace"` +- Default: `"log"` +- Env override: `LOG_LEVEL` +- Notes: controls which console methods are emitted (`error` and `metrics` + always emit) +- Source: `src/zod.ts`, `src/dare-console-logger.ts` + +### logSecondsBetweenMetrics + +- Scope: logger +- Type: number (`>= 0`) +- Default: `500` +- Env override: `LOG_SECONDS_BETWEEN_METRICS` +- Notes: `0` disables periodic metrics writes +- Source: `src/zod.ts`, `src/dare-console-logger.ts` + +### logPriorityThresholdBytes + +- Scope: logger +- Type: number (`>= 10`) +- Default: `1048576` (1 MiB) +- Env override: `LOG_PRIORITY_THRESHOLD_BYTES` +- Notes: when queue size exceeds threshold, flushing is prioritized immediately +- Source: `src/zod.ts`, `src/dare-console-logger.ts` + +### logMeta + +- Scope: logger +- Type: `Record | null` +- Default: `null` +- Env override: none +- Notes: merged into emitted `context.meta`; merged shallowly on updates +- Source: `src/zod.ts`, `src/dare-console-logger.ts` + +### logObjects + +- Scope: logger +- Type: boolean (`true/false/yes/no` accepted by parser) +- Default: `false` +- Env override: `LOG_OBJECTS` +- Notes: logs objects directly to console; forces `logSecondsBetweenMetrics=0` + when enabled +- Source: `src/zod.ts`, `src/dare-console-logger.ts` + +### logPretty + +- Scope: logger +- Type: boolean (`true/false/yes/no`) +- Default: `false` +- Env override: `LOG_PRETTY` +- Notes: pretty-prints JSON logs via `console.log` +- Source: `src/zod.ts`, `src/dare-console-logger.ts` + +### logWithConsole + +- Scope: logger +- Type: boolean (`true/false/yes/no`) +- Default: `false` +- Env override: `LOG_WITH_CONSOLE` +- Notes: bypasses queue and writes via `console.log(JSON.stringify(...))` +- Source: `src/zod.ts`, `src/dare-console-logger.ts` + +### silentInit + +- Scope: logger +- Type: boolean (`true/false/yes/no`) +- Default: `false` +- Env override: none +- Notes: intended to suppress init/update debug logs. Current implementation + does not apply `initLogger({ silentInit })` into `newConfig`, so this setting + is effectively always defaulted during init. +- Source: `src/zod.ts`, `src/dare-console-logger.ts` + +## Environment Variables + +### LOG_LEVEL + +- Maps to: `logLevel` +- Example: `LOG_LEVEL=info` +- Source: `src/dare-console-logger.ts`, `README.md` + +### LOG_SECONDS_BETWEEN_METRICS + +- Maps to: `logSecondsBetweenMetrics` +- Example: `LOG_SECONDS_BETWEEN_METRICS=60` +- Source: `src/dare-console-logger.ts`, `README.md` + +### LOG_PRIORITY_THRESHOLD_BYTES + +- Maps to: `logPriorityThresholdBytes` +- Example: `LOG_PRIORITY_THRESHOLD_BYTES=262144` +- Source: `src/dare-console-logger.ts` + +### LOG_OBJECTS + +- Maps to: `logObjects` +- Example: `LOG_OBJECTS=true` +- Source: `src/dare-console-logger.ts`, `README.md` + +### LOG_PRETTY + +- Maps to: `logPretty` +- Example: `LOG_PRETTY=true` +- Source: `src/dare-console-logger.ts`, `README.md` + +### LOG_WITH_CONSOLE + +- Maps to: `logWithConsole` +- Example: `LOG_WITH_CONSOLE=true` +- Source: `src/dare-console-logger.ts`, `README.md` + +## Express Middleware Settings (`expressLoggerMiddleware`) + +### doNotLogURLs (express) + +- Scope: Express middleware +- Type: `RegExp` +- Default: unset +- Notes: skip logging for matching `req.originalUrl` +- Source: `src/express.ts` + +## Fresh Middleware Settings (`freshV1LoggerPlugin`) + +### setCookies (fresh) + +- Scope: Fresh middleware +- Type: `Cookie[]` +- Default: unset +- Notes: additional cookies appended to response +- Source: `src/fresh.ts` + +### doNotLogURLs (fresh) + +- Scope: Fresh middleware +- Type: `RegExp` +- Default: unset +- Notes: skip logging for matching route path +- Source: `src/fresh.ts` + +## Hono Middleware Settings (`honoLoggerMiddleware`) + +### doNotLogURLs (hono) + +- Scope: Hono middleware +- Type: `RegExp` +- Default: unset +- Notes: skip logging for matching request path +- Source: `src/hono.ts` + +## SolidStart Middleware Settings (`solidStartLoggerMiddleware`) + +### doNotLogURLs (solidstart) + +- Scope: SolidStart middleware +- Type: `RegExp` +- Default: unset +- Notes: skip logging for matching request path +- Source: `src/solidstart.ts` + +## Request Context Inputs (Header/Cookie Driven) + +### x-request-id + +- Scope: request context +- Default/fallback: generated `"--"` +- Source: `src/utils.ts` + +### x-trace-path + +- Scope: request context +- Type: comma-separated list +- Default/fallback: `[requestId]` when request id is generated +- Source: `src/utils.ts` + +### x-correlation-id / correlationId cookie + +- Scope: request context +- Default/fallback: `"unknown"` +- Source: `src/utils.ts` + +### authorization / session cookie + +- Scope: request context +- Notes: extracts `sessionId` from token payload when present +- Default/fallback: `"unknown"` +- Source: `src/utils.ts` + +### x-application-name / applicationName cookie + +- Scope: request context +- Default/fallback: `"postman"` for Postman user-agent; otherwise `"unknown"` +- Source: `src/utils.ts` + +### x-application-version / applicationVersion cookie + +- Scope: request context +- Default/fallback: `"unknown"` +- Source: `src/utils.ts` + +### user-agent + +- Scope: request context +- Default/fallback: `"unknown"` +- Source: `src/utils.ts` + +## Implementation Notes + +- `LoggingConfig` type in `src/zod.ts` does not currently include + `logWithConsole`, but runtime schema/config uses it. +- `silentInit` exists in schema/type, but is not wired into `initLogger`'s + `newConfig` object. diff --git a/mod.ts b/mod.ts index 2ab0de4..34e2b40 100644 --- a/mod.ts +++ b/mod.ts @@ -6,4 +6,9 @@ export { export { MetricsTracker } from "./src/dare-metrics.ts"; export { expressLoggerMiddleware, type ExpressOptions } from "./src/express.ts"; export { freshV1LoggerPlugin, type FreshV1Options } from "./src/fresh.ts"; +export { honoLoggerMiddleware, type HonoOptions } from "./src/hono.ts"; +export { + solidStartLoggerMiddleware, + type SolidStartOptions, +} from "./src/solidstart.ts"; export { type LoggingConfig } from "./src/zod.ts"; diff --git a/src/dare-console-logger.ts b/src/dare-console-logger.ts index 04354e9..3917151 100644 --- a/src/dare-console-logger.ts +++ b/src/dare-console-logger.ts @@ -39,14 +39,14 @@ interface LogObject { // set defaults const defaultValues = { - logLevel:"log", + logLevel: "log", logSecondsBetweenMetrics: 500, logPriorityThresholdBytes: 1024 * 1024, // 1 MB logMeta: null, logObjects: false, logPretty: false, logWithConsole: false, -} +}; export let logConfig = baseZodLogConfig.parse(defaultValues); let logLevelValue = logLevels[logConfig.logLevel]; @@ -195,7 +195,7 @@ const logFormatter = ( }; } else if (typeof part === "string") { if (message) { - message += ' ' + part; + message += " " + part; } else { message = part; } @@ -233,7 +233,7 @@ const logFormatter = ( }; function queueLog(logObject: LogObject) { - if (logConfig.logPretty ) { + if (logConfig.logPretty) { origLog(JSON.stringify(logObject, undefined, 2)); } else if (logConfig.logWithConsole) { origLog(JSON.stringify(logObject)); @@ -274,12 +274,17 @@ export function initLogger( const updates = baseZodLogConfig.partial().parse(configuration); const newConfig = { logLevel: process.env.LOG_LEVEL ?? updates.logLevel ?? logConfig.logLevel, - logSecondsBetweenMetrics: process.env.LOG_SECONDS_BETWEEN_METRICS ?? updates.logSecondsBetweenMetrics ?? logConfig.logSecondsBetweenMetrics, - logPriorityThresholdBytes: process.env.LOG_PRIORITY_THRESHOLD_BYTES ?? updates.logPriorityThresholdBytes ?? logConfig.logPriorityThresholdBytes, - logMeta: {...logConfig.logMeta, ...updates.logMeta}, - logObjects: process.env.LOG_OBJECTS ?? updates.logObjects ?? logConfig.logObjects, - logPretty: process.env.LOG_PRETTY ?? updates.logPretty ?? logConfig.logPretty, - logWithConsole: process.env.LOG_WITH_CONSOLE ?? updates.logWithConsole ?? logConfig.logWithConsole, + logSecondsBetweenMetrics: process.env.LOG_SECONDS_BETWEEN_METRICS ?? + updates.logSecondsBetweenMetrics ?? logConfig.logSecondsBetweenMetrics, + logPriorityThresholdBytes: process.env.LOG_PRIORITY_THRESHOLD_BYTES ?? + updates.logPriorityThresholdBytes ?? logConfig.logPriorityThresholdBytes, + logMeta: { ...logConfig.logMeta, ...updates.logMeta }, + logObjects: process.env.LOG_OBJECTS ?? updates.logObjects ?? + logConfig.logObjects, + logPretty: process.env.LOG_PRETTY ?? updates.logPretty ?? + logConfig.logPretty, + logWithConsole: process.env.LOG_WITH_CONSOLE ?? updates.logWithConsole ?? + logConfig.logWithConsole, }; logConfig = baseZodLogConfig.parse(newConfig); diff --git a/src/hono.ts b/src/hono.ts new file mode 100644 index 0000000..91231cc --- /dev/null +++ b/src/hono.ts @@ -0,0 +1,115 @@ +import { asyncLocalStorage, storeItemFromRequest } from "./utils.ts"; + +/** + * Options for the Hono logger + * @param doNotLogURLs - a regex to match URLs that should not be logged + */ +export type HonoOptions = { + doNotLogURLs?: RegExp; +}; + +type HJson = + | string + | number + | boolean + | Date + | null + | { [key: string]: HJson } + | HJson[]; + +type HonoLikeContext = { + req: { + raw: Request; + path?: string; + method?: string; + }; + res: Response; + header(name: string, value: string): void; +}; + +type HonoLikeNext = () => Promise; + +function resLogData( + req: Request, + response: Response | null, + path: string, + status: string, +): Record { + const url = new URL(req.url); + return { + type: "api_call", + status, + request: { + method: req.method, + path: path, + search: url.search, + }, + response: { + statusMessage: response?.statusText || "unknown", + statusCode: response?.status || "unknown", + }, + }; +} + +/** + * Add a request logger to a Hono app + * @param options + */ +export function honoLoggerMiddleware( + options?: HonoOptions, +): (ctx: HonoLikeContext, next: HonoLikeNext) => Promise { + return async (ctx, next): Promise => { + const req = ctx.req.raw; + const url = new URL(req.url); + const path = ctx.req.path || url.pathname; + + if (options?.doNotLogURLs?.test(path)) { + await next(); + return; + } + + const storeItem = storeItemFromRequest( + req.headers, + { method: req.method, path }, + ); + ctx.header("x-request-id", storeItem.trace.requestId); + + await asyncLocalStorage.run(storeItem, async () => { + console.trace(() => { + return [ + `api request start ${path}`, + { + type: "request", + request: { + method: req.method, + url: req.url, + }, + }, + ]; + }); + + try { + await next(); + console.info( + `request end ${req.method} ${path}`, + { metrics: storeItem.metrics?.getMetrics() || {} }, + resLogData(req, ctx.res, path, "success"), + ); + } catch (error) { + console.error( + `request end error ${path}`, + resLogData(req, ctx.res, path, "error"), + { metrics: storeItem.metrics?.getMetrics() || {} }, + { + javascriptError: { + message: error instanceof Error ? error.message : String(error), + data: JSON.parse(JSON.stringify(error)), + stack: error instanceof Error ? error.stack : "no stack trace", + }, + }, + ); + throw error; + } + }); + }; +} diff --git a/src/solidstart.ts b/src/solidstart.ts new file mode 100644 index 0000000..bca283c --- /dev/null +++ b/src/solidstart.ts @@ -0,0 +1,135 @@ +import { asyncLocalStorage, storeItemFromRequest } from "./utils.ts"; + +/** + * Options for the SolidStart logger + * @param doNotLogURLs - a regex to match URLs that should not be logged + */ +export type SolidStartOptions = { + doNotLogURLs?: RegExp; +}; + +type HJson = + | string + | number + | boolean + | Date + | null + | { [key: string]: HJson } + | HJson[]; + +type SolidStartLikeEvent = { + request: Request; + url?: URL; +}; + +function withHeader(response: Response, key: string, value: string): Response { + try { + response.headers.set(key, value); + return response; + } catch { + const headers = new Headers(response.headers); + headers.set(key, value); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } +} + +function resLogData( + req: Request, + response: Response | null, + path: string, + status: string, +): Record { + const url = new URL(req.url); + return { + type: "api_call", + status, + request: { + method: req.method, + path: path, + search: url.search, + }, + response: { + statusMessage: response?.statusText || "unknown", + statusCode: response?.status || "unknown", + }, + }; +} + +/** + * Add a request logger middleware for SolidStart server middleware pipelines. + * @param options + */ +export function solidStartLoggerMiddleware( + options?: SolidStartOptions, +): ( + event: SolidStartLikeEvent, + next: () => Promise, +) => Promise { + return async ( + event: SolidStartLikeEvent, + next: () => Promise, + ): Promise => { + const req = event.request; + const url = event.url ?? new URL(req.url); + + if (options?.doNotLogURLs?.test(url.pathname)) { + return await next(); + } + + const storeItem = storeItemFromRequest( + req.headers, + { method: req.method, path: url.pathname }, + ); + + let response: Response | null = null; + + await asyncLocalStorage.run(storeItem, async () => { + console.trace(() => { + return [ + `api request start ${url.pathname}`, + { + type: "request", + request: { + method: req.method, + url: req.url, + }, + }, + ]; + }); + + try { + response = await next(); + response = withHeader( + response, + "x-request-id", + storeItem.trace.requestId, + ); + + console.info( + `request end ${req.method} ${url.pathname}`, + { metrics: storeItem.metrics?.getMetrics() || {} }, + resLogData(req, response, url.pathname, "success"), + ); + } catch (error) { + console.error( + `request end error ${url.pathname}`, + resLogData(req, response, url.pathname, "error"), + { metrics: storeItem.metrics?.getMetrics() || {} }, + { + javascriptError: { + message: error instanceof Error ? error.message : String(error), + data: JSON.parse(JSON.stringify(error)), + stack: error instanceof Error ? error.stack : "no stack trace", + }, + }, + ); + throw error; + } + }); + return response!; + }; +}