Understand exactly what your code is doing in real time, no debugger needed.
LogEye is a frictionless runtime logger for Python that shows variable changes, function calls, and data mutations as they happen.
Think of it as "print debugging" just better - automated, structured, easy to drop into, and remove from any codebase
pip install logeyeNo dependencies, Python 3.8 to 3.14, any operating system.
from logeye import log
@log
def add(a, b):
return a + b
add(2, 3)
@log(mode="edu")
def add_edu(a, b):
return a + b
add_edu(2, 3)Output:
[0.004s] example.py:9 (call) add args=(2, 3)
[0.004s] example.py:4 (set) add.a = 2
[0.004s] example.py:4 (set) add.b = 3
[0.004s] example.py:6 (return) add args=(2, 3) -> 5
[0.004s] Calling add_edu(2, 3)
[0.004s] Defined add_edu.a = 2
[0.004s] Defined add_edu.b = 3
[0.004s] add_edu(2, 3) returned 5
- Who is it for?
- What does it do?
- Quick start
- Educational Mode
- Logging functions
- Advanced function logging
- Logging objects
- Messages
- Utility functions
- Output format
- Demos
- Inspiration
- Limitations
- Contributing
- Contact
- License
LogEye helps you see how your code executes step by step.
Perfect for:
- beginners learning programming
- students studying algorithms
- teachers explaining concepts
No more scattered print() calls. No debugger setup. Simply run your code and see everything.
Why keep doing this?
print(x)
print(y)
print(queue)When a single @log suffices?
Popped (0, 'A') from queue
Pushed (1, 'B') onto queue
Pushed (4, 'C') onto queue
Core features:
- educational mode for algorithm tracing
- log values with automatic variable name inference
- trace function calls, local variables, and returns
- track object and data structure mutations in real time
- format messages using f-string, template, or scope variables
Advanced features:
- filter variables and control verbosity (
level,filter,threshold) - log to files or stdout
- recursively track nested structures
- AST-based name inference (including multi-line assignments)
However, keep in mind that name inference is best-effort and may not be accurate in some more extreme cases.
Without changing your code, LogEye shows:
- function calls and returns
- local variables inside functions
- object attribute changes
- list / dict / set / deque mutations,
heapqoperations included - nested structures
- recursion and call depth
- generators, exceptions, builtins, async functions and async generators
LogEye only watches. A list your function sorts in place is still sorted for the caller, an object keeps its class and its methods, and two names for the same list stay two names for the same list:
from logeye import log
@log
def sort_scores(scores):
scores.sort(reverse=True)
results = [72, 95, 58]
sort_scores(results)
print(results)[0.003s] example.py:10 (call) sort_scores args=([72, 95, 58])
[0.004s] example.py:4 (set) sort_scores.scores = [72, 95, 58]
[0.004s] example.py:6 (change) sort_scores.scores.sort(...) -> [95, 72, 58]
[0.004s] example.py:6 (return) sort_scores args=([72, 95, 58]) -> None
[95, 72, 58]
from logeye import log
x = log(10)
message = log("Hello from {name}", name="Matt")
@log(level="call")
def add(a, b):
something = 2 + 2 # Unused
return a + b
add(2, 2)
name = "Matt"
message2 = log("Hello from $name")
config = log({"debug": True, "port": 8080})
config.port = 9090
config["debug"] = FalseExample output:
[0.003s] example.py:3 (set) x = 10
[0.004s] example.py:4 (set) message = 'Hello from Matt'
[0.004s] example.py:13 (call) add args=(2, 2)
[0.004s] example.py:10 (return) add args=(2, 2) -> 4
[0.005s] example.py:16 (set) message2 = 'Hello from Matt'
[0.005s] example.py:18 (set) config = {'debug': True, 'port': 8080}
[0.005s] example.py:19 (change) config.port = 9090
[0.005s] example.py:20 (change) config.debug = False
Educational mode is designed to make algorithms read like a story instead of a trace. :)
Instead of raw internal logs, it shows:
- clean function calls
- meaningful variable changes
- human-readable operations
- minimal noise
Enable it with:
from logeye import log, set_mode
# Globally
set_mode("edu")
# Locally
@log(mode="edu")
def my_function(): ...The same Dijkstra run, with a heapq priority queue, from demos/06_graphs.py.
Narrowed with filter=["distances", "queue"]:
(set) dijkstra.distances = {'A': inf, 'B': inf, 'C': inf, 'D': inf}
(change) dijkstra.distances.A = 0
(set) dijkstra.queue = [(0, 'A')]
(change) heappop(dijkstra.queue) -> (0, 'A') | []
(change) dijkstra.distances.B = 1
(change) heappush(dijkstra.queue, (1, 'B')) -> [(1, 'B')]
(change) dijkstra.distances.C = 4
(change) heappush(dijkstra.queue, (4, 'C')) -> [(1, 'B'), (4, 'C')]
(change) heappop(dijkstra.queue) -> (1, 'B') | [(4, 'C')]
(change) dijkstra.distances.C = 3
(change) heappush(dijkstra.queue, (3, 'C')) -> [(3, 'C'), (4, 'C')]
...
Calling dijkstra({'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}, 'A')
Defined dijkstra.graph = {'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}
Defined dijkstra.start = 'A'
Defined dijkstra.distances = {'A': inf, 'B': inf, 'C': inf, 'D': inf}
Set dijkstra.distances.A = 0
Defined dijkstra.visited = set()
Defined dijkstra.queue = [(0, 'A')]
Popped (0, 'A') from queue
Defined dijkstra.distance = 0
Defined dijkstra.node = 'A'
Added A to visited
...
Set dijkstra.distances.B = 1
Pushed (1, 'B') onto queue
...
dijkstra({'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}, 'A') returned {'A': 0, 'B': 1, 'C': 3, 'D': 4}
-
Function calls become readable:
Calling foo(1, b=2) -
No raw
args/kwargsdictionaries -
Internal noise is removed:
- no
<func ...> - no test/module prefixes
- no file names or line numbers
- no
-
Data structure operations are human-friendly:
Added 5 to the end of arr
from logeye import log, l
l("FACTORIAL")
@log(mode="edu")
def factorial(n):
if n == 1:
return 1
return n * factorial(n - 1)
factorial(5)Output:
[0.003s] example.py:3 FACTORIAL
[0.004s] Calling factorial(5)
[0.004s] Defined factorial.n = 5
[0.004s] Calling factorial#2(4)
[0.004s] Defined factorial#2.n = 4
[0.004s] Calling factorial#3(3)
[0.004s] Defined factorial#3.n = 3
[0.004s] Calling factorial#4(2)
[0.004s] Defined factorial#4.n = 2
[0.004s] Calling factorial#5(1)
[0.004s] Defined factorial#5.n = 1
[0.004s] factorial#5(1) returned 1
[0.004s] factorial#4(2) returned 2
[0.004s] factorial#3(3) returned 6
[0.004s] factorial#2(4) returned 24
[0.004s] factorial(5) returned 120
It’s especially useful for:
- learning recursion
- understanding sorting algorithms
- teaching data structures
- quickly verifying logic without a debugger
demos/05_educational_mode.py walks through bubble sort, binary search and radix sort.
Decorate a function or wrap it with log:
from logeye import log
@log
def add(a, b):
total = a + b
return total
add(2, 3)[0.004s] example.py:10 (call) add args=(2, 3)
[0.004s] example.py:4 (set) add.a = 2
[0.004s] example.py:4 (set) add.b = 3
[0.004s] example.py:6 (set) add.total = 5
[0.004s] example.py:7 (return) add args=(2, 3) -> 5
This will log:
- the function call
- local variable changes inside the function
- the return value
Recursion, nested functions, generators, exceptions, methods, builtins (log(len)), async functions and async
generators are covered too, see demos/02_functions.py and demos/10_native_and_async.py.
You can customise how functions are logged using @log(...):
from logeye import log
@log(level="call")
def foo():
x = 10
return x- "call" - only function calls and returns
- "state" - no call lines, just variable changes and the result
- "full" - full tracing (default)
from logeye import log
@log(filter=["x"])
def foo():
x = 10
y = 20
return x + yOnly selected variables will be logged.
from logeye import log
@log(threshold={"velocity": ("relative", 0.1), "hp": ("absolute", 5)})
def tick():
velocity = 10.0
hp = 100
...Small numeric changes are skipped until they add up to the threshold. A threshold can be a number (absolute), an
("absolute" | "relative", value) tuple, or a mapping from variable name to either.
from logeye import log
@log(filepath="logs/my_func.log")
def foo():
x = 10
return xLogs for this function will be written to a file instead of stdout.
from logeye import log
@log(level="state", filter=["queue"], filepath="queue.log")
def process():
queue = []
queue.append(1)
queue.append(2)demos/08_filtering.py shows each option on the same function.
log() wraps a mapping into a logged object you can also use with attribute access:
from logeye import log
settings = log({"theme": "dark", "volume": 3})
settings.theme = "light"
settings.volume += 1Decorate a class with @log to see every instance being built and changed:
from logeye import log
@log
class User:
def __init__(self):
self.name = "Matt"
self.active = True
user = User()
user.name = "For"[0.003s] example.py:3 (set) settings = {'theme': 'dark', 'volume': 3}
[0.003s] example.py:4 (change) settings.theme = 'light'
[0.003s] example.py:5 (change) settings.volume = 4
[0.003s] example.py:15 (call) user <- User.__init__
[0.003s] example.py:11 (set) user.name = 'Matt'
[0.003s] example.py:12 (set) user.active = True
[0.003s] example.py:16 (change) user.name = 'For'
Instances stay real instances: type(user) is User still holds. See demos/04_classes.py.
Use log() with a string to emit a formatted message:
from logeye import log
name = "Matt"
email = "mattfor@relaxy.xyz"
log("Current user: $name, email: $email")
# Also works like this!
log("Current user: {}, email: {}", "Matt", "mattfor@relaxy.xyz")[0.003s] example.py:5 Current user: Matt, email: mattfor@relaxy.xyz
[0.004s] example.py:8 Current user: Matt, email: mattfor@relaxy.xyz
$name placeholders come from the caller's scope, {} placeholders from the arguments. $fpath, $rpath and
$apath give the current file's name, relative path and absolute path. Only the template is expanded: a value that
happens to contain $something or {} is printed as is.
Logs a value without wrapping or changing it, then keeps reporting the variable it was assigned to.
Disable or enable logging globally.
Controls how file paths are shown in output.
Accepted values:
absoluteprojectfile
Replace the default formatter.
Signature:
func(elapsed, kind, name, value, filename, lineno)The formatter may take fewer positional arguments (func(elapsed, kind, name, value)), and returning None drops the
line.
Restore the built-in formatter.
Remove the line tracer watch() installs, restoring whatever tracer was there before.
By default, messages look like this:
(note, the path by default is relative to the run directory of the file you're launching the module in)
[0.123s] path/to/file.py:42 (set) x = 10
For plain messages:
[0.123s] path/to/file.py:42 some text
from logeye import set_global_log_file, toggle_global_log_file
set_global_log_file("logs/app.log")
toggle_global_log_file(True)All logs will now be written to the specified file.
To disable: toggle_global_log_file(False)
demos/ is a guided tour, one topic per file, from a one-minute quick start to educational mode, classes, filtering, file output and async functions. Run one, or all of them:
python demos/01_quickstart.py
python demos/run_all.pyEvery demo's exact output is checked in CI and kept in tests/demo_output/, so you can read what each one prints without running it.
Idea came to be during Warsaw IT Days 2026. During the Python lecture "Logging module adventures".
I thought there definitely was an easier way to do it without repeating yourself constantly, and it turns out there was!
- variable name inference is best-effort and may fail in complex or highly dynamic expressions
- some edge cases (e.g. deeply nested calls, chained expressions, unusual syntax) may fall back to a generic name or plain message
- lambda functions are not automatically traced unless explicitly wrapped with
log() - function tracing relies on
sys.settrace()and may introduce overhead in performance-sensitive code - debuggers and coverage tools use
sys.settrace()too: stepping through a@logfunction with pdb, or measuring its coverage, does not work while it runs - a coroutine awaited inside a logged one is traced only when it is logged itself
- a change to a container that no call on its line explains, such as
arr[i] = xon a list your function received or a change made inside another function, is reported as the container's new value; calls likearr.sort()orheapq.heappush(arr, x)are always named log(container)returns a logging copy: keep using the returned value, not the original- logging inside heavily recursive or multithreaded code may produce noisy or hard-to-follow output
- AST-based analysis requires access to source files and may not work correctly in environments without source code ( e.g. compiled/obfuscated code, some REPLs)
- tuple assignment tracking depends on call order and may behave unexpectedly in complex expressions
- logging output is intended for debugging and introspection, not structured logging or production telemetry
Bug reports, ideas and pull requests are welcome. CONTRIBUTING.md covers the setup, the checks CI runs, and how releases are made.
If you have questions, ideas, or run into issues:
- please open an issue!
- or email me mattfor@relaxy.xyz
- or add me on discord @mattfor
- @OutSquareCapital Helped with typing and refactoring