Skip to main content

A minimal, ergonomic python logging wrapper

Project description

.github/workflows/ci.yml

ergolog

A minimal, ergonomic Python logging wrapper

from ergolog import eg — one entry point for tags, counters, timers, wide events, and trace. Everything context-scoped, thread-safe, and composable.

  • Named loggerseg('name'), nested with one('two')
  • Tags — stack and nest for context; keyword tags, callable values, auto-UUIDs
  • Counters — live-updating tag values; enumerate loops, accumulate totals
  • Timers — elapsed timing with .lap() split times and named laps
  • Wide events — accumulate context, emit a single line; counters, timers, and laps resolve at emit time
  • Trace — function-level decorator for timing and entry logging
  • Thread-safe — tag isolation via contextvars

Why ergolog?

ergolog is built around two ideas: tags carry ambient context, and wide events capture operation outcomes.

Tags propagate through the call stack. When you open a with eg.tag(request_id='abc') block, every log line inside it carries [request_id=abc]. Tags nest, so a child scope adds to the stack instead of replacing it. The stack unwinds automatically on exception, and because tags are stored in contextvars, each thread and async task maintains its own isolated context.

Wide events complement tags. Where tags answer "what context am I in?", events answer "what happened?". An event accumulates fields like counters, timers, named laps, and custom data, then emits a single structured line at operation boundaries. Timer and counter values evaluate at emit time, so the final line always shows total duration and all intermediate splits without manual bookkeeping.

ergolog won't stomp on your app's logging setup — set ERGOLOG_NO_AUTO_SETUP=1 and it does nothing on import.

Installation

uv add ergolog
# or
pip install ergolog

Configuration

No configuration needed — ergolog auto-configures on import with colored output to stdout.

from ergolog import eg
eg.info('works immediately')

To customize at runtime, use eg.config:

eg.config.add_output('file', path='app.jsonl', format='json')  # JSON to file (append mode)
eg.config.set_format('json')         # Switch stdout to JSON
eg.config.remove_output('stdout')   # Remove an output

Valid formats: 'default' (colored), 'plain' (no ANSI), 'json' (JSONL). Valid outputs: 'stdout', 'stderr', 'file'.

For log level and propagation, use the standard logging API:

import logging
eg._logger.setLevel(logging.WARNING)    # filter by level
eg._logger.propagate = False            # prevent double-logging in frameworks

Using ergolog in a library? Set ERGOLOG_NO_AUTO_SETUP=1 before importing so the host app owns logging:

import os
os.environ['ERGOLOG_NO_AUTO_SETUP'] = '1'
from ergolog import eg

Environment variables (all "off switches"):

  • ERGOLOG_NO_AUTO_SETUP — don't configure any handlers on import
  • ERGOLOG_NO_COLORS — disable ANSI color output
  • ERGOLOG_NO_TIME — disable timestamp prefix

Basic Usage

from ergolog import eg

eg.debug('debug')
eg.info('info')
eg.warning('warning')
eg.error('error')
eg.critical('critical')
2025-04-25 15:30:01,234 [DEBUG   ] ergo (main.py:3) debug
2025-04-25 15:30:01,235 [INFO    ] ergo (main.py:4) info
2025-04-25 15:30:01,236 [WARNING ] ergo (main.py:5) warning
2025-04-25 15:30:01,237 [ERROR   ] ergo (main.py:6) error
2025-04-25 15:30:01,238 [CRITICAL] ergo (main.py:7) critical

Colors: DEBUG is blue, INFO is green, WARNING is yellow, ERROR is red, CRITICAL is magenta. Timestamps and file locations are dimmed. Set ERGOLOG_NO_COLORS=1 to disable.

Named Loggers

from ergolog import eg

eg('test').debug('named logger')
15:30:01,234 [DEBUG   ] ergo.test (main.py:3) named logger

Child loggers:

one = eg('one')
two = one('two')

two.info('child logger')
15:30:01,235 [INFO    ] ergo.one.two (main.py:4) child logger

Tags

from ergolog import eg

with eg.tag('tag1'):
    eg.info('one tag')
    with eg.tag('tag2'):
        eg.info('two tags')
    eg.info('one tag again')
15:30:01,235 [INFO    ] ergo [tag1] (main.py:4) one tag
15:30:01,236 [INFO    ] ergo [tag1, tag2] (main.py:6) two tags
15:30:01,237 [INFO    ] ergo [tag1] (main.py:7) one tag again

Tag Decorator

from ergolog import eg

@eg.tag('inner')
def inner():
    eg.info('test')

@eg.tag('outer')
def outer():
    eg.debug('before')
    inner()
    eg.debug('after')

eg.debug('start')
outer()
eg.debug('end')
15:30:01,234 [DEBUG   ] ergo (main.py:14) start
15:30:01,235 [DEBUG   ] ergo [outer] (main.py:9) before
15:30:01,236 [INFO    ] ergo [outer, inner] (main.py:5) test
15:30:01,237 [DEBUG   ] ergo [outer] (main.py:12) after

Keyword Tags

from ergolog import eg

with eg.tag(keyword='tags', comma='multiple'):
    eg.debug('')
    with eg.tag('regular tag'):
        eg.info('')
        with eg.tag(more='keywords'):
            eg.info('')
    eg.debug('')
15:30:01,234 [DEBUG   ] ergo [keyword=tags, comma=multiple] (main.py:4) 
15:30:01,235 [INFO    ] ergo [keyword=tags, comma=multiple, regular tag] (main.py:6) 
15:30:01,236 [INFO    ] ergo [keyword=tags, comma=multiple, regular tag, more=keywords] (main.py:8)
15:30:01,237 [DEBUG   ] ergo [keyword=tags, comma=multiple] (main.py:9)

Auto-generated IDs

from ergolog import eg

with eg.tag(job=eg.uid):
    eg.info('first')
    with eg.tag(job=eg.uid):
        eg.info('nested')
    eg.info('first again')
15:30:01,235 [INFO    ] ergo [job=34bfbe] (main.py:4) first
15:30:01,236 [INFO    ] ergo [job=34bfbe, job=80dbc9] (main.py:6) nested
15:30:01,237 [INFO    ] ergo [job=34bfbe] (main.py:7) first again

Any zero-arg callable works as a tag value — it's evaluated once when the tag context is entered.

Counters

from ergolog import eg

counter = eg.counter()
with eg.tag(step=counter):
    eg.info('start')       # [step=0]
    counter += 1
    eg.info('middle')      # [step=1]
    counter += 1
    eg.info('end')         # [step=2]
15:30:01,235 [INFO    ] ergo [step=0] (main.py:4) start
15:30:01,236 [INFO    ] ergo [step=1] (main.py:6) middle
15:30:01,237 [INFO    ] ergo [step=2] (main.py:8) end

Counters are tag values that update live — each log line shows the current value.

Loop enumeration

loops = eg.counter()
with eg.tag(i=loops):
    for item in loops.count(['a', 'b', 'c']):
        eg.info(f'item {item}')
15:30:01,235 [INFO    ] ergo [i=1] (main.py:4) item a
15:30:01,236 [INFO    ] ergo [i=2] (main.py:4) item b
15:30:01,237 [INFO    ] ergo [i=3] (main.py:4) item c

Accumulation

total = eg.counter()
with eg.tag(bytes=total):
    total += 1024
    eg.info('chunk')       # [bytes=1024]
    total += 512
    eg.info('chunk')       # [bytes=1536]
15:30:01,235 [INFO    ] ergo [bytes=1024] (main.py:4) chunk
15:30:01,236 [INFO    ] ergo [bytes=1536] (main.py:6) chunk

Timers

from ergolog import eg

with eg.timer(lambda t: eg.debug(f'took {t}S')):
    eg.info('before')
    # ... do stuff
    eg.info('after')
15:30:01,235 [INFO    ] ergo (main.py:4) before
15:30:01,236 [INFO    ] ergo (main.py:6) after
15:30:01,237 [DEBUG   ] ergo (main.py:3) took 0.101S

Or use as a context manager to capture elapsed time:

with eg.timer() as t:
    # ... do stuff
    pass

eg.debug(f'took {t} S')
15:30:01,235 [DEBUG   ] ergo (main.py:5) took 0.123 S

Laps

Use .lap() to take split times without stopping the timer. Returns elapsed seconds as a float:

with eg.timer() as t:
    fetch_data()
    fetch_time = t.lap()      # returns float, timer keeps running
    process_data()
    process_time = t.lap()    # returns float from start
    eg.debug(f'fetch={fetch_time:.3f}s process={process_time:.3f}s total={t.elapsed:.3f}s')
15:30:01,235 [DEBUG   ] ergo (main.py:6) fetch=0.103s process=0.456s total=0.456s

Use .lap('name') to record named laps — these are auto-collected by events:

with eg.timer() as t:
    fetch_data()
    t.lap('fetch')       # records lap name + time
    process_data()
    t.lap('process')     # records lap name + time
    # t.laps == {'fetch': 0.103, 'process': 0.456}
15:30:01,235 [DEBUG   ] ergo (main.py:3) fetch=0.103s process=0.456s total=0.456s

Timers as Tag Values

Timers can be used as keyword tag values, showing live elapsed time on each log line:

t = eg.timer()
with eg.tag(elapsed=t):
    eg.info('start')
    sleep(0.1)
    eg.info('middle')
    sleep(0.1)
    eg.info('end')
15:30:01,234 [INFO    ] ergo [elapsed=0.000s] (main.py:3) start
15:30:01,334 [INFO    ] ergo [elapsed=0.100s] (main.py:5) middle
15:30:01,434 [INFO    ] ergo [elapsed=0.200s] (main.py:7) end

Trace

eg.trace() is a debugging tool for local development. It logs function entry, elapsed time, and optionally arguments and return values. A WARNING is emitted at decoration time as a reminder not to leave it in production code.

from ergolog import eg

@eg.trace()
def my_function(a, b):
    return a + b

my_function(2, 2)
15:30:01,234 [WARNING ] ergo [trace=my_function] (ergolog.py:241) registering trace
15:30:01,235 [DEBUG   ] ergo [trace=my_function] (ergolog.py:252) done in 0.000S

By default, arguments and return values are omitted. Use log_args=True when you need full visibility into a function call.

Structured Logging

Tags are available on LogRecord as both record.tags (display string) and record.tag_list (raw list), so custom formatters can access them for JSON or structured output.

Wide Events

Wide events accumulate context throughout an operation and emit a single log line at the end. Use them to capture operation boundaries — "what happened" rather than "how we got here."

from ergolog import eg

# Context manager (auto-emit on exit)
with eg.event(user='alice', action='checkout') as e:
    e.set(cart={'items': 3, 'total': 9999})
    e.set(payment={'method': 'card'})
    # On exit: emits one log line with all context + duration
15:30:01,235 [INFO    ] ergo (main.py:4) user=alice action=checkout cart={'items': 3, 'total': 9999} payment={'method': 'card'} | duration=0.234s

Manual Emit

e = eg.event(user='bob')
e.set(action='search', query='ergonomics')
e.emit()
15:30:01,235 [INFO    ] ergo (main.py:3) user=bob action=search query=ergonomics | duration=0.001s

Outcome Levels

Events default to INFO. Use e.error() or e.warn() to mark the outcome:

# Success → INFO
with eg.event(user='alice') as e:
    process_payment()

# Success with concern → WARNING
with eg.event(user='bob') as e:
    result = process_payment()
    if result.used_fallback:
        e.warn('used fallback payment method')

# Failure → ERROR
try:
    with eg.event(user='charlie') as e:
        raise ValueError('insufficient funds')
except ValueError:
    pass
15:30:01,235 [INFO    ] ergo (main.py:2) user=alice | duration=0.001s
15:30:01,235 [WARNING ] ergo (main.py:7) warning=used fallback payment method user=bob | duration=0.001s
15:30:01,235 [ERROR   ] ergo (main.py:12) ValueError: insufficient funds user=charlie | duration=0.002s

Sealed After Emit

Events emit exactly once. After emit(), further set() calls are ignored:

e = eg.event(user='alice')
e.emit()
e.set(ignored='data')  # Ignored, event is sealed
15:30:01,235 [INFO    ] ergo (main.py:2) user=alice | duration=0.001s

Capturing Tags

Events capture the current tag stack at emit time:

with eg.tag(request_id='abc123'):
    e = eg.event(operation='tagged')
    e.set(extra='data')
    e.emit()
15:30:01,235 [INFO    ] ergo (main.py:2) operation=tagged extra=data request_id=abc123 | duration=0.001s

Counters in Events

Counters passed to e.set() evaluate at emit time — the event shows their final value:

counter = eg.counter()
with eg.event(op='batch') as e:
    e.set(processed=counter)
    for item in items:
        process(item)
        counter += 1
# Event shows: processed=5 (or whatever the final count is)
15:30:01,235 [INFO    ] ergo (main.py:7) op=batch processed=5 | duration=0.034s

Timers in Events

Timers passed to e.set() evaluate at emit time — the event shows total elapsed:

t = eg.timer()
with eg.event(op='export') as e:
    e.set(duration=t)
    export_data()
# Event shows: duration=1.234 (total elapsed)
15:30:01,235 [INFO    ] ergo (main.py:4) op=export duration=1.234 | duration=1.234s

Named Laps in Events

Named laps on timers are auto-collected into events. This is the primary mechanism for breaking down multi-stage operations:

t = eg.timer()
with eg.event(op='pipeline') as e:
    e.set(duration=t)
    fetch_data()
    t.lap('fetch')
    process_data()
    t.lap('process')
    save_results()
    t.lap('save')
# Event includes: duration=0.789 fetch=0.101 process=0.456 save=0.232
15:30:01,235 [INFO    ] ergo (main.py:9) op=pipeline duration=0.789 fetch=0.101 process=0.456 save=0.232 | duration=0.789s

You can also set lap values explicitly for full control:

with eg.event(op='task') as e:
    t = eg.timer()
    fetch_data()
    e.set(fetch_time=t.lap())
    process_data()
    e.set(process_time=t.lap())
15:30:01,235 [INFO    ] ergo (main.py:6) op=task fetch_time=0.101 process_time=0.456 | duration=0.456s

When to Use Events vs Regular Logs

Pattern Purpose
Regular logs (eg.info/debug/warning) Trace execution, debug flow
Wide events (eg.event()) Capture operation outcome

Use both together for complete visibility:

with eg.event(operation='export') as e:
    e.set(format='pdf', pages=24)
    eg.debug('starting export')
    export_to_pdf(doc)
    eg.debug('export complete')
    # Event emits: what happened (one line)
15:30:01,234 [DEBUG   ] ergo [operation=export] (main.py:3) starting export
15:30:01,345 [DEBUG   ] ergo [operation=export] (main.py:5) export complete
15:30:01,345 [INFO    ] ergo (main.py:6) operation=export format=pdf pages=24 | duration=0.111s

JSON Formatter

For structured logging to files or log aggregation systems:

from ergolog import eg

# Switch to JSON output
eg.config.set_format('json')

eg.info('hello')

Output (one line per log):

{"timestamp":"2024-01-15T10:23:45.123Z","level":"INFO","name":"ergo","message":"hello","tags":{},"location":{"file":"main.py","line":4,"function":"<module>"}}

For wide events, the full context is included:

{"timestamp":"...","level":"INFO","name":"ergo","message":"user=alice action=checkout ...","event":{"user":"alice","action":"checkout","cart":{"items":3},"duration_s":0.234},"tags":{"request_id":"abc123"}}

You can also send JSON to a file while keeping colored output on stdout:

eg.config.add_output('file', path='app.jsonl', format='json')

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ergolog-0.2.0.tar.gz (28.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ergolog-0.2.0-py3-none-any.whl (15.2 kB view details)

Uploaded Python 3

File details

Details for the file ergolog-0.2.0.tar.gz.

File metadata

  • Download URL: ergolog-0.2.0.tar.gz
  • Upload date:
  • Size: 28.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.12 {"installer":{"name":"uv","version":"0.10.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for ergolog-0.2.0.tar.gz
Algorithm Hash digest
SHA256 cbb821b21c8f4d8fffdd5f22e3b2a5e0bdbc91e6388707d075ab793b15722cda
MD5 2ae717b8e77238c6f8d93f114f4d5be1
BLAKE2b-256 a4619db7eaf016eebd20d727b82272e6cc74655ff32cf2aaa7f8c9ff393b0df4

See more details on using hashes here.

File details

Details for the file ergolog-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: ergolog-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 15.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.12 {"installer":{"name":"uv","version":"0.10.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for ergolog-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 80ac82d29408b3c8e218b711dab5071fe3c36f2af9f1e8fd5320a048294bd6b2
MD5 df63330b66b135743db824fb61021fcd
BLAKE2b-256 cc3365d1a54f9a4c31bb46b05d501bdd4e902aa99e091e1d7c221656f2506e80

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page