---
title: "Simple Logging"
description: "Use evlog for individual structured messages, with the same drains and redaction as wide events, and adapt existing logger call sites."
canonical_url: "https://www.evlog.dev/learn/simple-logging"
---
# Simple Logging

> Use evlog for individual structured messages, with the same drains and redaction as wide events, and adapt existing logger call sites.

The `log` API is evlog's general-purpose logger. Use it the way you'd use pino, consola, or `console.log`: every call emits a structured event through the same drain pipeline as wide events. The two modes coexist; neither is an upgrade of the other.

<callout color="neutral" icon="i-lucide-globe">

Looking for the same API in CLIs, libraries, jobs, and edge? Start with [Standalone TypeScript](https://www.evlog.dev/integrate/frameworks/standalone) and [Cloudflare Workers](https://www.evlog.dev/integrate/frameworks/cloudflare-workers).

</callout>

<callout color="info" icon="i-lucide-sparkles">

In Nuxt, `log` is **auto-imported**. No import statement needed.

</callout>

## Setup

For standalone projects (non-Nuxt), initialize once at startup:

```typescript [src/index.ts]
import { initLogger, log } from 'evlog'

initLogger({
  env: { service: 'my-app' },
})

log.info('app', 'Server started')
```

<callout color="info" icon="i-lucide-info">

`env.service` defaults to `'app'` if not specified. Only set it if you want a custom service name.

</callout>

`log` also works with no `initLogger()` call at all: every call prints with the defaults. That is fine for a scratch script, and it is how the default import below behaves. In production it means no redaction and no drain, so the first emitted event logs a one-time warning pointing at `initLogger({ drain })`.

## Call it two ways

### Tagged Logs

Pass a tag and a message for quick, readable output:

```typescript [src/index.ts]
import { log } from 'evlog'

log.info('auth', 'User logged in')
log.warn('cache', 'Cache miss for key user:42')
log.error('payment', 'Stripe webhook failed')
log.debug('router', 'Matched route /api/checkout')
```

```bash [Output (Pretty)]
10:23:45.612 [auth] User logged in
10:23:45.613 [cache] Cache miss for key user:42
10:23:45.614 ERROR [payment] Stripe webhook failed
10:23:45.615 [router] Matched route /api/checkout
```

### Interpolate values into the message

When the message contains `%s`, `%d`, or `%j` and extra arguments follow, evlog formats it before emitting. Call sites that come from pino or consola keep their printf style instead of becoming template strings:

```typescript [src/index.ts]
log.info('auth', 'User %s logged in from %s', 'alice', '10.0.0.7')
log.info('payment', 'Retrying charge for order %d', 4091)
log.info('debug', 'Payload %j', { orderId: 4091, total: 9999 })
```

```bash [Output (Pretty)]
10:23:45.612 [auth] User alice logged in from 10.0.0.7
10:23:45.613 [payment] Retrying charge for order 4091
10:23:45.614 [debug] Payload {"orderId":4091,"total":9999}
```

The same form works without a tag: when the first string contains a specifier, evlog treats it as the message and the call as untagged.

```typescript [src/index.ts]
log.info('user %s logged in', 'alice') // emits [log] user alice logged in
```

<table>
<thead>
  <tr>
    <th>
      Specifier
    </th>
    
    <th>
      Formats the argument as
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        %s
      </code>
    </td>
    
    <td>
      string, objects and arrays as JSON
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        %d
      </code>
    </td>
    
    <td>
      number, coerced with <code>
        Number()
      </code>
      
      , bigints as <code>
        10n
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        %j
      </code>
    </td>
    
    <td>
      JSON, with a string fallback for values JSON cannot serialize
    </td>
  </tr>
</tbody>
</table>

Interpolation runs only when extra arguments are present. A message with a `%` and no arguments is logged unchanged; once arguments are involved, write `%%` for a literal percent.

<callout color="info" icon="i-lucide-info">

Formatting applies to the message string only. Field values in a structured event are never treated as format input.

</callout>

### Structured Events

Pass an object for rich, queryable events that flow through the drain pipeline:

```typescript [src/index.ts]
import { log } from 'evlog'

log.info({ action: 'user_login', userId: 42, method: 'oauth', provider: 'github' })
log.error({ action: 'sync_failed', source: 'postgres', target: 's3', error: 'connection_timeout' })
```

```bash [Output (Pretty)]
10:23:45.612 INFO [my-app]
  ├─ action: user_login
  ├─ userId: 42
  ├─ method: oauth
  └─ provider: github
```

<callout color="info" icon="i-lucide-info">

**Tagged logs** are optimized for console readability. **Structured events** (object form) produce full wide events that flow through the drain pipeline to external services.

</callout>

## Choose a level, and what it costs

<table>
<thead>
  <tr>
    <th>
      Level
    </th>
    
    <th>
      Method
    </th>
    
    <th>
      When to use
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        fatal
      </code>
    </td>
    
    <td>
      <code>
        log.fatal()
      </code>
    </td>
    
    <td>
      Unrecoverable failures: the request or process cannot continue. Always kept, even when sampling would drop it
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        info
      </code>
    </td>
    
    <td>
      <code>
        log.info()
      </code>
    </td>
    
    <td>
      Normal operations: startup, shutdown, successful actions
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      <code>
        log.warn()
      </code>
    </td>
    
    <td>
      Unexpected but recoverable situations: cache miss, retry, deprecation
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        error
      </code>
    </td>
    
    <td>
      <code>
        log.error()
      </code>
    </td>
    
    <td>
      Failures that need attention: API errors, timeouts, invalid state
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        debug
      </code>
    </td>
    
    <td>
      <code>
        log.debug()
      </code>
    </td>
    
    <td>
      Development-only details: SQL queries, intermediate state, routing
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        trace
      </code>
    </td>
    
    <td>
      <code>
        log.trace()
      </code>
    </td>
    
    <td>
      Very fine-grained details below <code>
        debug
      </code>
      
      . Dropped by default; opt in with <code>
        sampling.rates.trace
      </code>
    </td>
  </tr>
</tbody>
</table>

<callout color="warning" icon="i-lucide-lightbulb">

`log.debug()` calls can be stripped from production builds using the [Vite Plugin](https://www.evlog.dev/reference/vite-plugin) or the Nuxt module's `strip` option.

</callout>

## Patterns worth copying

### Application Lifecycle

```typescript [src/index.ts]
import { log } from 'evlog'

log.info('app', 'Starting server on port 3000')
log.info({ action: 'db_connected', host: 'localhost', database: 'mydb', pool: 10 })
log.info('app', 'Ready to accept connections')
```

### Background Tasks

```typescript [src/jobs/cleanup.ts]
import { log } from 'evlog'

log.info({ action: 'cron_started', job: 'cleanup', schedule: '0 */6 * * *' })
log.info({ action: 'cron_completed', job: 'cleanup', deleted: 42, duration: 1200 })
```

### Utility Functions

```typescript [src/utils/webhook.ts]
import { log } from 'evlog'

function processWebhook(payload: WebhookPayload) {
  log.info({ action: 'webhook_received', type: payload.type, source: payload.source })

  if (!isValid(payload)) {
    log.warn({ action: 'webhook_invalid', type: payload.type, reason: 'missing_signature' })
    return
  }
}
```

## Send these logs to a drain

When using the object form, events are sent through the [drain pipeline](https://www.evlog.dev/integrate/adapters/overview) just like wide events:

```typescript [src/index.ts]
import { initLogger, log } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'

initLogger({
  env: { service: 'my-app' },
  drain: createAxiomDrain(),
})

log.info({ action: 'deploy', version: '1.2.3', region: 'us-east-1' })
```

## Migrating from console / pino / consola / winston

Pick the tab matching your current logger to see the **before** call style. The **after (evlog)** snippet underneath is the same regardless of where you came from.

<code-group>

```typescript [pino]
import pino from 'pino'

const log = pino({ name: 'checkout' })

log.info({ event: 'checkout_started' })
log.info({ event: 'cart_loaded', items: 3, total: 9999 })
log.warn({ event: 'inventory_low', sku: 'SKU-42' })
log.error({ event: 'payment_failed', reason: 'card_declined' })
```

```typescript [winston]
import { createLogger, format, transports } from 'winston'

const log = createLogger({
  defaultMeta: { service: 'checkout' },
  format: format.json(),
  transports: [new transports.Console()],
})

log.info({ event: 'checkout_started' })
log.info({ event: 'cart_loaded', items: 3, total: 9999 })
log.warn({ event: 'inventory_low', sku: 'SKU-42' })
log.error({ event: 'payment_failed', reason: 'card_declined' })
```

```typescript [consola]
import { consola } from 'consola'

const log = consola.withTag('checkout')

log.info('Starting checkout')
log.info('cart loaded', { items: 3, total: 9999 })
log.warn('inventory low', { sku: 'SKU-42' })
log.error('payment failed', { reason: 'card_declined' })
```

```typescript [console.log]
console.log('[checkout] Starting checkout')
console.log('[checkout] cart loaded', { items: 3, total: 9999 })
console.warn('[checkout] inventory low', { sku: 'SKU-42' })
console.error('[checkout] payment failed', { reason: 'card_declined' })
```

</code-group>

All four become this, with no formatter, transport, or peer-dep wiring:

```typescript [After (evlog)]
import { initLogger, log } from 'evlog'

initLogger({ env: { service: 'checkout' } })

log.info({ event: 'checkout_started' })
log.info({ event: 'cart_loaded', items: 3, total: 9999 })
log.warn({ event: 'inventory_low', sku: 'SKU-42' })
log.error({ event: 'payment_failed', reason: 'card_declined' })
```

`initLogger` is one line at boot. The drain, redaction, sampling, pretty/JSON switching, and level filtering are all wired by default: no `pino-pretty` peer dep, no winston transport assembly, no consola reporter setup.

If you want the drop-in feel of `import pino from 'pino'` while you evaluate evlog, the default export is the same `log` API with no setup line:

```typescript [src/index.ts]
import logger from 'evlog'

logger.info('checkout', 'Starting checkout')
logger.info({ event: 'checkout_started' })
```

Every call prints immediately. Once you want a drain, redaction, or wide events, add the `initLogger` line and keep calling the same methods; the default import and `log` are the same object, so nothing else moves.

<callout color="neutral" icon="i-lucide-arrow-right">

Want the full side-by-side (feature comparison tables, honest gaps, per-feature mapping)? See [evlog vs pino, winston, consola](https://www.evlog.dev/reference/vs-other-loggers).

</callout>

## Pairing with wide events

`log` and `createLogger` live inside the same logger. Use `log.*` for events that stand alone (startup messages, ad-hoc warnings, debug traces) and reach for `createLogger` when you want one event that captures an entire operation. They share the global drain, redaction, and types, so pick per call.

```typescript [scripts/sync-data.ts]
import { initLogger, log, createLogger } from 'evlog'

initLogger({ env: { service: 'sync-worker' } })

log.info('sync', 'Worker starting')

const run = createLogger({ source: 'postgres', target: 's3' })
try {
  const records = await fetchRecords()
  run.set({ found: records.length })

  for (const record of records) {
    await syncOne(record)
    log.debug({ event: 'record_synced', id: record.id })
  }

  run.set({ status: 'complete', synced: records.length })
} catch (err) {
  log.error({ event: 'sync_failed' })
  run.error(err as Error)
  throw err
} finally {
  run.emit()
}

log.info('sync', 'Worker finished')
```

The `log.*` calls give you a real-time trail in development; the `createLogger` block gives your dashboard one queryable row per run. Both go through the same drain.

## Next Steps

- [Wide Events](https://www.evlog.dev/learn/wide-events): Accumulate context and emit comprehensive events
- [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link`
- [Configuration](https://www.evlog.dev/reference/configuration): All `initLogger` options
- [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send events to Axiom, Sentry, PostHog, and more
- [Standalone TypeScript](https://www.evlog.dev/integrate/frameworks/standalone): Scripts, workers, and libraries without a web framework
- [evlog vs other loggers](https://www.evlog.dev/reference/vs-other-loggers): Side-by-side with pino, winston, consola

---

- [Wide Events](https://www.evlog.dev/learn/wide-events)
- [Configuration](https://www.evlog.dev/reference/configuration)


## Sitemap

See the full [sitemap](https://www.evlog.dev/sitemap.md) for all pages.
