Zero-dependency observability for Deno — structured JSON logging for Loki and native Prometheus metrics, with drop-in middleware for Hono, Oak, and Fresh.
- Structured JSON Logger — Loki/Promtail-ready JSON lines to stdout/stderr
- Native Prometheus Metrics — Counter, Gauge, Histogram with text exposition
format. No
prom-clientor npm dependencies. - Framework Middleware — Request logging + HTTP metrics for Hono, Oak, and Fresh 2.x
- Request ID Correlation — Integrates with Hono's built-in
requestId()middleware - Child Loggers — Inherit parent context, add request-scoped fields
- Zero Dependencies — Core logger and metrics have no external dependencies. Framework adapters depend only on their respective framework.
deno add jsr:@sourcya/loogOr add to your import map in deno.json:
{
"imports": {
"@sourcya/loog": "jsr:@sourcya/loog@^0.1",
"@sourcya/loog/": "jsr:/@sourcya/loog@^0.1/"
}
}import { createLogger, createMetrics } from "@sourcya/loog";
// Structured logging
const log = createLogger("my-service");
log.info("Server started", { port: 3000 });
// → {"ts":"2026-05-31T12:00:00.000Z","level":"info","module":"my-service","msg":"Server started","port":3000}
// Prometheus metrics
const metrics = createMetrics({ prefix: "myapp_" });
const counter = metrics.counter({
name: "requests_total",
help: "Total requests",
});
counter.inc();
console.log(metrics.serialize());
// → # HELP myapp_requests_total Total requests
// → # TYPE myapp_requests_total counter
// → myapp_requests_total 1import { createLogger } from "@sourcya/loog/logger";
const log = createLogger("my-module");
log.info("Hello");The first argument is the module name, included as the module field in
every log entry.
Four levels in ascending severity: debug → info → warn → error.
const log = createLogger("app", { level: "warn" });
log.debug("skipped"); // not emitted
log.info("skipped"); // not emitted
log.warn("emitted"); // ✓
log.error("emitted"); // ✓Default level is "info".
Pass a record of extra fields as the second argument to any log method:
log.info("User login", { userId: 42, ip: "192.168.1.1" });Output:
{
"ts": "2026-05-31T12:00:00.000Z",
"level": "info",
"module": "auth",
"msg": "User login",
"userId": 42,
"ip": "192.168.1.1"
}Create child loggers that inherit parent fields and add their own. Useful for adding request-scoped context:
const log = createLogger("api", { fields: { service: "payments" } });
const reqLog = log.child({ requestId: "abc-123" });
reqLog.info("Processing payment", { amount: 99.99 });
// → {..., "service":"payments", "requestId":"abc-123", "amount":99.99}Child fields override parent fields on key collision. Children inherit the parent's log level.
Every log call emits exactly one JSON line. Field reference:
| Field | Type | Description |
|---|---|---|
ts |
string | ISO 8601 timestamp |
level |
string | Log level (debug/info/warn/error) |
module |
string | Module name from createLogger |
msg |
string | Log message |
... |
any | Extra fields from args or parent |
error-level messages go to stderr; all others go to stdout.
Error objects in fields are automatically serialized to plain objects with
name, message, and stack — they won't silently become {}:
log.error("Failed", { err: new Error("connection timeout") });
// → { ..., "err": { "name": "Error", "message": "connection timeout", "stack": "..." } }const log = createLogger("app", {
timestamp: () => String(Date.now()),
});import { createMetrics } from "@sourcya/loog/metrics";
const metrics = createMetrics({
prefix: "myapp_", // prepended to all metric names
defaultLabels: { env: "production", service: "api" },
});A monotonically increasing metric. Use for: total requests, events processed, errors.
const requests = metrics.counter({
name: "http_requests_total",
help: "Total HTTP requests",
labels: ["method", "status"],
});
requests.inc({ method: "GET", status: "200" }); // +1
requests.inc({ method: "POST", status: "201" }, 5); // +5
requests.inc(); // +1 (no labels)A metric that can go up and down. Use for: active connections, queue depth, temperature.
const connections = metrics.gauge({
name: "active_connections",
help: "Current active connections",
labels: ["pool"],
});
connections.set({ pool: "primary" }, 10);
connections.inc({ pool: "primary" }); // 11
connections.dec({ pool: "primary" }, 3); // 8Tracks the distribution of observed values in configurable buckets. Use for: request duration, response size.
const duration = metrics.histogram({
name: "request_duration_seconds",
help: "Request duration in seconds",
labels: ["method"],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 5], // custom buckets
});
duration.observe({ method: "GET" }, 0.042);Default buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
const output = metrics.serialize();
// Returns Prometheus text exposition format:
// # HELP myapp_http_requests_total Total HTTP requests
// # TYPE myapp_http_requests_total counter
// myapp_http_requests_total{env="production",method="GET",service="api",status="200"} 1The contentType property returns the correct MIME type for Prometheus:
metrics.contentType;
// "text/plain; version=0.0.4; charset=utf-8"import { Hono } from "hono";
import { requestId } from "hono/request-id";
import { createLogger, createMetrics } from "@sourcya/loog";
import { httpLogger, httpMetrics, metricsEndpoint } from "@sourcya/loog/hono";
const logger = createLogger("my-app");
const metrics = createMetrics({ prefix: "myapp_" });
const app = new Hono();
// 1. Generate request IDs (Hono built-in)
app.use("*", requestId());
// 2. Structured request logging (one JSON line per response)
app.use("*", httpLogger({ logger }));
// 3. HTTP metrics (request count + duration histogram)
app.use("*", httpMetrics({ metrics }));
// 4. Expose /metrics for Prometheus
app.get("/metrics", metricsEndpoint(metrics));Logs one structured JSON line per request on response completion.
Options:
| Option | Type | Default |
|---|---|---|
logger |
Logger |
createLogger("http") |
skip |
(path: string) => boolean |
Skips /healthz, /ready, /health, /metrics |
Output:
{
"ts": "...",
"level": "info",
"module": "http",
"msg": "GET /api/users 200 42ms",
"method": "GET",
"path": "/api/users",
"status": 200,
"duration": 42,
"requestId": "550e8400-..."
}- 5xx responses →
errorlevel - 4xx responses →
warnlevel - Others →
infolevel requestIdis included if Hono'srequestId()middleware is active
Records two metrics per request:
| Metric | Type | Labels |
|---|---|---|
http_request_duration_seconds |
Histogram | method, route, status_code |
http_requests_total |
Counter | method, route, status_code |
The route label uses c.req.routePath for the normalized route pattern (e.g.
/api/users/:id), not the raw URL.
Options:
| Option | Type | Default |
|---|---|---|
metrics |
Metrics |
Creates a new registry |
skip |
(path: string) => boolean |
Skips /metrics, /healthz, /ready, /health |
buckets |
number[] |
Default histogram buckets |
Returns a Hono handler that serves metrics.serialize() with the correct
Content-Type header.
import { Application, Router } from "@oak/oak";
import { createLogger, createMetrics } from "@sourcya/loog";
import {
oakHttpLogger,
oakHttpMetrics,
oakMetricsEndpoint,
} from "@sourcya/loog/oak";
const logger = createLogger("my-app");
const metrics = createMetrics({ prefix: "myapp_" });
const app = new Application();
// Request ID middleware (Oak doesn't have one built-in)
app.use(async (ctx, next) => {
const requestId =
ctx.request.headers.get("X-Request-ID") ?? crypto.randomUUID();
ctx.state.requestId = requestId;
ctx.response.headers.set("X-Request-ID", requestId);
await next();
});
// Structured request logging
app.use(oakHttpLogger({ logger }));
// HTTP metrics collection
app.use(oakHttpMetrics({ metrics }));
// Expose /metrics
const metricsRouter = oakMetricsEndpoint(metrics);
app.use(metricsRouter.routes());
app.use(metricsRouter.allowedMethods());Same behavior as the Hono httpLogger but for Oak. Reads ctx.state.requestId
for correlation.
Same metrics as the Hono adapter. Uses ctx.request.url.pathname for the path
label.
Returns an Oak Router with a GET handler at the specified path (defaults to
"/metrics").
Fresh 2.x uses Hono as its underlying HTTP framework. The @sourcya/loog/fresh
subpath provides Hono middleware with Fresh-idiomatic naming.
import { App } from "@fresh/core";
import { createLogger, createMetrics } from "@sourcya/loog";
import {
freshHttpLogger,
freshHttpMetrics,
freshMetricsEndpoint,
} from "@sourcya/loog/fresh";
const logger = createLogger("my-fresh-app");
const metrics = createMetrics({ prefix: "myapp_" });
const app = new App();
app.use(freshHttpLogger({ logger }));
app.use(freshHttpMetrics({ metrics }));
app.get("/metrics", freshMetricsEndpoint(metrics));Since Fresh 2.x is built on Hono, you can also use @sourcya/loog/hono
directly — the middleware is identical.
Add this to your prometheus.yml:
scrape_configs:
- job_name: "my-service"
scrape_interval: 15s
static_configs:
- targets: ["my-service:3000"]
metrics_path: "/metrics"Since loog outputs structured JSON to stdout, configure Promtail or Grafana Alloy to scrape container logs and parse JSON fields:
Promtail config.yaml:
scrape_configs:
- job_name: my-service
static_configs:
- targets: [localhost]
labels:
job: my-service
__path__: /var/log/containers/my-service*.log
pipeline_stages:
- json:
expressions:
level: level
module: module
msg: msg
requestId: requestId
- labels:
level:
module:PromQL (Metrics):
# Request rate per second
rate(myapp_http_requests_total[5m])
# 95th percentile request duration
histogram_quantile(0.95, rate(myapp_http_request_duration_seconds_bucket[5m]))
# Error rate (5xx responses)
rate(myapp_http_requests_total{status_code=~"5.."}[5m])
LogQL (Loki):
# All error logs from my-service
{job="my-service"} | json | level="error"
# Slow requests (duration > 1000ms)
{job="my-service"} | json | module="http" | duration > 1000
# Logs for a specific request
{job="my-service"} | json | requestId="550e8400-e29b-41d4-a716-446655440000"
export function createLogger(module: string, options?: LoggerOptions): Logger;
export function createMetrics(options?: MetricsOptions): Metrics;export function createLogger(module: string, options?: LoggerOptions): Logger;
interface Logger {
debug(msg: string, fields?: Record<string, unknown>): void;
info(msg: string, fields?: Record<string, unknown>): void;
warn(msg: string, fields?: Record<string, unknown>): void;
error(msg: string, fields?: Record<string, unknown>): void;
child(fields: Record<string, unknown>): Logger;
}
interface LoggerOptions {
level?: "debug" | "info" | "warn" | "error";
fields?: Record<string, unknown>;
timestamp?: () => string;
}export function createMetrics(options?: MetricsOptions): Metrics;
interface Metrics {
counter<L extends string>(config: MetricConfig<L>): Counter<L>;
histogram<L extends string>(config: HistogramConfig<L>): Histogram<L>;
gauge<L extends string>(config: MetricConfig<L>): Gauge<L>;
serialize(): string;
contentType: string;
}
interface Counter<L extends string> {
inc(labels?: Record<L, string>, value?: number): void;
}
interface Gauge<L extends string> {
set(labels: Record<L, string>, value: number): void;
inc(labels?: Record<L, string>, value?: number): void;
dec(labels?: Record<L, string>, value?: number): void;
}
interface Histogram<L extends string> {
observe(labels: Record<L, string>, value: number): void;
}
interface MetricConfig<L extends string> {
name: string;
help: string;
labels?: readonly L[];
}
interface HistogramConfig<L extends string> extends MetricConfig<L> {
buckets?: number[];
}
interface MetricsOptions {
prefix?: string;
defaultLabels?: Record<string, string>;
}export function httpLogger(options?: HttpLoggerOptions): MiddlewareHandler;
export function httpMetrics(options?: HttpMetricsOptions): MiddlewareHandler;
export function metricsEndpoint(metrics: Metrics): Handler;export function oakHttpLogger(
options?: OakHttpLoggerOptions,
): (ctx: Context, next: Next) => Promise<void>;
export function oakHttpMetrics(
options?: OakHttpMetricsOptions,
): (ctx: Context, next: Next) => Promise<void>;
export function oakMetricsEndpoint(
metrics: Metrics,
path?: string,
): Router;export function freshHttpLogger(
options?: FreshHttpLoggerOptions,
): MiddlewareHandler;
export function freshHttpMetrics(
options?: FreshHttpMetricsOptions,
): MiddlewareHandler;
export function freshMetricsEndpoint(metrics: Metrics): Handler;Runnable examples in the examples/ directory:
| Example | Description |
|---|---|
basic-logger.ts |
Standalone structured logging |
custom-metrics.ts |
Creating and observing metrics |
hono-app.ts |
Full Hono app with all middleware |
oak-app.ts |
Full Oak app with all middleware |
fresh-app.ts |
Fresh 2.x app with middleware |
Run any example:
deno run --allow-net examples/hono-app.ts# Run tests
deno task test
# Lint (including JSR rules)
deno task lint
# Type check
deno task check
# Dry-run publish (verify no slow types)
deno task publish:dry