Skip to content
Merged
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
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { registerOTel } = await import("@vercel/otel")
registerOTel({ serviceName: "myapp" })
const { createLogger, setGlobalLogger } = await import("@tetratelabs/logging")
setGlobalLogger(createLogger({ name: "myapp", level: process.env.LOG_LEVEL ?? "info" }))
const { createLogger, setGlobalLogger, parseLevel } = await import("@tetratelabs/logging")
setGlobalLogger(createLogger({ name: "myapp", level: parseLevel(process.env.LOG_LEVEL) }))
}
}
```
Expand Down Expand Up @@ -99,6 +99,17 @@ interface LoggerOptions {
}
```

The `level` you pass is threaded into the underlying pino sink, so
`createLogger({ level: "debug" })` actually emits debug records on the
default stream. An explicit `pino.level` still wins if you set both.

### `parseLevel(value?)`

Accepts a string (typically `process.env.LOG_LEVEL`) and returns a valid
`Level`. Unknown values fall back to `"info"` rather than silencing the
logger. Pino aliases are mapped for ergonomics: `"trace"` → `"debug"`,
`"fatal"` → `"error"`.

### Logger methods

```ts
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tetratelabs/logging",
"version": "0.3.1",
"version": "0.3.2",
"description": "Structured logging with guaranteed metrics, built on pino and OpenTelemetry.",
"type": "module",
"packageManager": "pnpm@9.1.0",
Expand Down
1 change: 1 addition & 0 deletions src/edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {
} from "./context.js"
export type { OperationInfo } from "./context.js"
export { getGlobalLogger, setGlobalLogger, log } from "./global.js"
export { parseLevel } from "./levels.js"

import { LoggerImpl } from "./logger.js"
import type { LoggerOptions, Logger } from "./types.js"
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {
} from "./context.js"
export type { OperationInfo } from "./context.js"
export { getGlobalLogger, setGlobalLogger, log } from "./global.js"
export { parseLevel } from "./levels.js"

import { context } from "@opentelemetry/api"
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"
Expand Down
5 changes: 5 additions & 0 deletions src/levels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export function parseLevel(env?: string): Level {
if (!env) return "info"
const normalized = env.toLowerCase().trim()
if (normalized in levelRanks) return normalized as Level
// Accept pino aliases so callers porting from pino don't get silence
// on familiar level names. trace is finer-grained than debug; we map
// it to debug. fatal is coarser than error; we map it to error.
if (normalized === "trace") return "debug"
if (normalized === "fatal") return "error"
return "info"
}

Expand Down
10 changes: 9 additions & 1 deletion src/sink-pino.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ import { toPinoLevel } from "./levels.js"
import type { Sink, SinkOptions } from "./sink.js"

export function createPinoSink(opts?: SinkOptions): Sink {
const pinoInstance = pino(opts?.pino ?? {})
// Thread the wrapper level into pino. Without this, pino defaults to
// "info" and silently drops debug records even when the wrapper would
// let them through, leaving the caller wondering why LOG_LEVEL=debug
// produces no debug output. An explicit `opts.pino.level` still wins.
const pinoOpts = { ...(opts?.pino ?? {}) }
if (opts?.level && pinoOpts.level === undefined) {
pinoOpts.level = toPinoLevel(opts.level)
}
const pinoInstance = pino(pinoOpts)

return {
write(level: Level, msg: string, fields: Record<string, unknown>) {
Expand Down
1 change: 1 addition & 0 deletions src/sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface Sink {

export interface SinkOptions {
name?: string
level?: Level
pino?: any
}

Expand Down
92 changes: 92 additions & 0 deletions test/sink-pino.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"
import { createPinoSink } from "../src/sink-pino"
import { createLogger } from "../src/index"
import { parseLevel } from "../src/levels"

// pino writes to process.stdout by default. Intercept it for the duration
// of each test so we can assert on which records pino actually emitted.
let stdoutLines: string[]
let originalWrite: typeof process.stdout.write

beforeEach(() => {
stdoutLines = []
originalWrite = process.stdout.write.bind(process.stdout)
;(process.stdout as any).write = (chunk: any) => {
stdoutLines.push(String(chunk))
return true
}
})

afterEach(() => {
process.stdout.write = originalWrite
})

function parsedLines(): any[] {
return stdoutLines
.join("")
.split("\n")
.filter((l) => l.length > 0)
.map((l) => JSON.parse(l))
}

describe("createPinoSink — level wiring", () => {
it("emits debug records when LoggerOptions.level is debug", () => {
const sink = createPinoSink({ level: "debug" })
sink.write("debug", "d", {})
sink.write("info", "i", {})

expect(parsedLines().map((l) => l.msg)).toEqual(["d", "i"])
})

it("regression: createLogger({ level: 'debug' }) emits debug records", () => {
// Before the fix, pino defaulted to level=info inside the sink, so
// debug records were dropped even though the wrapper allowed them.
const logger = createLogger({ level: "debug" })
logger.debug("debug-visible")
logger.info("info-visible")

const msgs = parsedLines().map((l) => l.msg)
expect(msgs).toContain("debug-visible")
expect(msgs).toContain("info-visible")
})

it("explicit opts.pino.level wins over wrapper level", () => {
const sink = createPinoSink({ level: "debug", pino: { level: "warn" } })
sink.write("debug", "drop-me", {})
sink.write("warn", "keep-me", {})

const msgs = parsedLines().map((l) => l.msg)
expect(msgs).not.toContain("drop-me")
expect(msgs).toContain("keep-me")
})

it("wrapper level=none silences pino entirely", () => {
const sink = createPinoSink({ level: "none" })
sink.write("error", "should-not-appear", {})
expect(parsedLines()).toEqual([])
})
})

describe("parseLevel — unknown LOG_LEVEL fallback", () => {
it("unknown values fall back to info, not silence", () => {
expect(parseLevel("verbose")).toBe("info")
expect(parseLevel("loud")).toBe("info")
expect(parseLevel("")).toBe("info")
expect(parseLevel(undefined)).toBe("info")
})

it("pino aliases are accepted", () => {
expect(parseLevel("trace")).toBe("debug")
expect(parseLevel("TRACE")).toBe("debug")
expect(parseLevel("fatal")).toBe("error")
expect(parseLevel("FATAL")).toBe("error")
})

it("known values are preserved", () => {
expect(parseLevel("debug")).toBe("debug")
expect(parseLevel("info")).toBe("info")
expect(parseLevel("warn")).toBe("warn")
expect(parseLevel("error")).toBe("error")
expect(parseLevel("none")).toBe("none")
})
})
Loading