Skip to content

feat(subscribers): add JSONL event log subscriber#6420

Open
cckellogg wants to merge 4 commits intomainfrom
chris/event-log
Open

feat(subscribers): add JSONL event log subscriber#6420
cckellogg wants to merge 4 commits intomainfrom
chris/event-log

Conversation

@cckellogg
Copy link
Contributor

@cckellogg cckellogg commented Mar 17, 2026

Changes Made

EventLogSubscriber writes query lifecycle events (start, optimization, execution, operator, stats) to a per-run JSONL file under ~/.daft/events/ directory as the default. This is configurable. Includes enable_event_log()/disable_event_log() helpers for global attachment with atexit cleanup.

Each query log is contained in a directory with following format <log_dir>/<query_id>/events.jsonl.

This is experimental and the api will probably change in future PRs.

Related Issues

sample log

{"event": "session_started", "ts": "2026-03-17T23:33:49.916Z", "daft_version": "0.3.0-dev0"}
{"event": "query_started", "ts": "2026-03-17T23:33:53.867Z", "query_id": "nimble-falcon-57ee1d"}
{"event": "plan_unoptimized", "ts": "2026-03-17T23:33:53.867Z", "query_id": "nimble-falcon-57ee1d", "plan": "{\"children\":[{\"batch_size\":10000,\"children\":[{\"children\":[{\"children\":[{\"children\":[],\"type\":\"Source\"}],\"projection\":[\"col(VendorID)\",\"col(total_amount)\",\"col(tpep_pickup_datetime)\"],\"type\":\"Project\"}],\"limit\":100000,\"type\":\"Limit\"}],\"type\":\"IntoBatches\"}],\"type\":\"Sink\"}"}
{"event": "optimization_started", "ts": "2026-03-17T23:33:53.867Z", "query_id": "nimble-falcon-57ee1d"}
{"event": "optimization_ended", "ts": "2026-03-17T23:33:54.164Z", "query_id": "nimble-falcon-57ee1d", "duration_ms": 298}
{"event": "plan_optimized", "ts": "2026-03-17T23:33:54.165Z", "query_id": "nimble-falcon-57ee1d", "plan": "{\"children\":[{\"batch_size\":10000,\"children\":[{\"children\":[{\"children\":[{\"children\":[],\"type\":\"Source\"}],\"limit\":100000,\"type\":\"Limit\"}],\"projection\":[\"col(VendorID)\",\"col(total_amount)\",\"col(tpep_pickup_datetime)\"],\"type\":\"Project\"}],\"type\":\"IntoBatches\"}],\"type\":\"Sink\"}"}
{"event": "execution_started", "ts": "2026-03-17T23:33:54.178Z", "query_id": "nimble-falcon-57ee1d"}
{"event": "plan_physical", "ts": "2026-03-17T23:33:54.178Z", "query_id": "nimble-falcon-57ee1d", "plan": "{\"approx_stats\":{\"approx_stats\":{\"acc_selectivity\":1.0,\"num_rows\":0,\"size_bytes\":0}},\"category\":\"BlockingSink\",\"children\":[{\"approx_stats\":{\"approx_stats\":{\"acc_selectivity\":1.0,\"num_rows\":0,\"size_bytes\":0}},\"category\":\"BlockingSink\",\"children\":[{\"approx_stats\":{\"approx_stats\":{\"acc_selectivity\":1.0,\"num_rows\":100000,\"size_bytes\":2400000}},\"category\":\"Intermediate\",\"children\":[{\"approx_stats\":{\"approx_stats\":{\"acc_selectivity\":1.0,\"num_rows\":100000,\"size_bytes\":2400000}},\"category\":\"Intermediate\",\"children\":[{\"approx_stats\":{\"approx_stats\":{\"acc_selectivity\":1.0,\"num_rows\":100000,\"size_bytes\":2400000}},\"category\":\"StreamingSink\",\"children\":[{\"approx_stats\":{\"approx_stats\":{\"acc_selectivity\":1.0,\"num_rows\":100000,\"size_bytes\":2400000}},\"category\":\"Source\",\"id\":0,\"name\":\"Read Parquet\",\"type\":\"ScanTask\"}],\"id\":1,\"name\":\"Limit 100000\",\"type\":\"Limit\"}],\"id\":2,\"name\":\"Rename & Reorder\",\"type\":\"Project\"}],\"id\":3,\"name\":\"Into Batches of 10000\",\"type\":\"IntoBatches\"}],\"id\":4,\"name\":\"CSV Write\",\"type\":\"Write\"}],\"id\":5,\"name\":\"Commit Write\",\"type\":\"CommitWrite\"}"}
{"event": "operator_started", "ts": "2026-03-17T23:33:54.365Z", "query_id": "nimble-falcon-57ee1d", "node_id": 0}
{"event": "stats", "ts": "2026-03-17T23:33:54.370Z", "query_id": "nimble-falcon-57ee1d", "node_id": 0, "metrics": {"bytes.read": 0, "rows.out": 0, "duration": 0}}
{"event": "stats", "ts": "2026-03-17T23:33:54.555Z", "query_id": "nimble-falcon-57ee1d", "node_id": 0, "metrics": {"bytes.read": 0, "rows.out": 0, "duration": 0}}
{"event": "stats", "ts": "2026-03-17T23:33:54.754Z", "query_id": "nimble-falcon-57ee1d", "node_id": 0, "metrics": {"rows.out": 0, "duration": 0, "bytes.read": 65536}}
{"event": "operator_started", "ts": "2026-03-17T23:33:54.901Z", "query_id": "nimble-falcon-57ee1d", "node_id": 1}
{"event": "operator_started", "ts": "2026-03-17T23:33:54.903Z", "query_id": "nimble-falcon-57ee1d", "node_id": 2}
{"event": "operator_started", "ts": "2026-03-17T23:33:54.905Z", "query_id": "nimble-falcon-57ee1d", "node_id": 3}
{"event": "operator_started", "ts": "2026-03-17T23:33:54.906Z", "query_id": "nimble-falcon-57ee1d", "node_id": 4}
{"event": "stats", "ts": "2026-03-17T23:33:54.953Z", "query_id": "nimble-falcon-57ee1d", "node_id": 3, "metrics": {"duration": 0, "rows.in": 60000, "rows.out": 50000}}
{"event": "stats", "ts": "2026-03-17T23:33:54.953Z", "query_id": "nimble-falcon-57ee1d", "node_id": 0, "metrics": {"bytes.read": 1653009, "duration": 0, "rows.out": 78091}}
{"event": "stats", "ts": "2026-03-17T23:33:54.953Z", "query_id": "nimble-falcon-57ee1d", "node_id": 1, "metrics": {"rows.out": 78091, "duration": 0, "rows.in": 78091}}
{"event": "stats", "ts": "2026-03-17T23:33:54.953Z", "query_id": "nimble-falcon-57ee1d", "node_id": 2, "metrics": {"rows.in": 78091, "rows.out": 78091, "duration": 1}}
{"event": "stats", "ts": "2026-03-17T23:33:54.953Z", "query_id": "nimble-falcon-57ee1d", "node_id": 4, "metrics": {"rows.written": 30000, "duration": 82, "rows.in": 30000, "bytes.written": 720000}}
{"event": "stats", "ts": "2026-03-17T23:33:54.970Z", "query_id": "nimble-falcon-57ee1d", "node_id": 1, "metrics": {"rows.in": 100000, "rows.out": 100000, "duration": 0}}
{"event": "operator_ended", "ts": "2026-03-17T23:33:54.970Z", "query_id": "nimble-falcon-57ee1d", "node_id": 1, "duration_ms": 69}
{"event": "stats", "ts": "2026-03-17T23:33:54.971Z", "query_id": "nimble-falcon-57ee1d", "node_id": 0, "metrics": {"duration": 0, "rows.out": 100000, "bytes.read": 2164041}}
{"event": "operator_ended", "ts": "2026-03-17T23:33:54.971Z", "query_id": "nimble-falcon-57ee1d", "node_id": 0, "duration_ms": 606}
{"event": "stats", "ts": "2026-03-17T23:33:54.989Z", "query_id": "nimble-falcon-57ee1d", "node_id": 2, "metrics": {"duration": 1, "rows.in": 100000, "rows.out": 100000}}
{"event": "operator_ended", "ts": "2026-03-17T23:33:54.989Z", "query_id": "nimble-falcon-57ee1d", "node_id": 2, "duration_ms": 87}
{"event": "stats", "ts": "2026-03-17T23:33:55.022Z", "query_id": "nimble-falcon-57ee1d", "node_id": 3, "metrics": {"rows.out": 100000, "rows.in": 100000, "duration": 0}}
{"event": "operator_ended", "ts": "2026-03-17T23:33:55.023Z", "query_id": "nimble-falcon-57ee1d", "node_id": 3, "duration_ms": 118}
{"event": "stats", "ts": "2026-03-17T23:33:55.037Z", "query_id": "nimble-falcon-57ee1d", "node_id": 4, "metrics": {"rows.in": 100000, "bytes.written": 2400000, "rows.written": 100000, "duration": 254}}
{"event": "operator_ended", "ts": "2026-03-17T23:33:55.037Z", "query_id": "nimble-falcon-57ee1d", "node_id": 4, "duration_ms": 131}
{"event": "operator_started", "ts": "2026-03-17T23:33:55.037Z", "query_id": "nimble-falcon-57ee1d", "node_id": 5}
{"event": "stats", "ts": "2026-03-17T23:33:55.049Z", "query_id": "nimble-falcon-57ee1d", "node_id": 5, "metrics": {"rows.in": 1, "rows.out": 1, "duration": 29}}
{"event": "operator_ended", "ts": "2026-03-17T23:33:55.049Z", "query_id": "nimble-falcon-57ee1d", "node_id": 5, "duration_ms": 12}
{"event": "execution_ended", "ts": "2026-03-17T23:33:55.049Z", "query_id": "nimble-falcon-57ee1d", "duration_ms": 871}
{"event": "result_out", "ts": "2026-03-17T23:33:55.050Z", "query_id": "nimble-falcon-57ee1d", "rows": 1}
{"event": "query_ended", "ts": "2026-03-17T23:33:55.050Z", "query_id": "nimble-falcon-57ee1d", "duration_ms": 1183, "status": "ok"}

@cckellogg cckellogg requested a review from a team March 17, 2026 23:44
@github-actions github-actions bot added the feat label Mar 17, 2026
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 17, 2026

Greptile Summary

This PR introduces EventLogSubscriber, a new experimental subscriber that writes structured JSONL event logs (query start/end, optimization, execution, operator lifecycle, stats) to per-query files under ~/.daft/events/<query_id>/events.jsonl. It also adds enable_event_log() / disable_event_log() convenience helpers with atexit cleanup, and a non-abstract close() hook on the Subscriber base class.

Key observations:

  • The per-query file approach (one events.jsonl per query_id directory) is a change from the PR description which showed a single flat session file; the current implementation is internally consistent and the tests reflect it.
  • close() iterates over open file handles but has no per-handle exception guard — if one close() raises, the remaining handles are leaked (see comment at line 120).
  • _clear_query_state omits _query_starts from its cleanup despite its docstring claiming to remove all leftover timing state; this is a latent footgun for future callers (see comment at line 59).
  • The test named test_event_log_subscriber_writes_session_header asserts the opposite of what its name implies (see comment at line 43).
  • Thread-safety (concurrent dict mutations from multi-threaded operator callbacks) and the inline from daft import __version__ import inside _write_session_header remain open from earlier review rounds.

Confidence Score: 3/5

  • Safe to merge as experimental feature, but thread-safety and minor resource-leak issues should be addressed before wider use.
  • The feature is gated as experimental and the core event-logging logic is correct. However, the thread-safety concern (unprotected concurrent dict mutations and file writes) from a previous review round is still unaddressed, the close() method can leak file handles if any individual close raises, and a test name actively misleads future developers. None of these are showstoppers for an experimental feature, but they lower confidence in production readiness.
  • daft/subscribers/events.py — close() exception safety and _clear_query_state completeness warrant a second look before removing the experimental label.

Important Files Changed

Filename Overview
daft/subscribers/events.py New JSONL event-log subscriber implementation. Core logic is correct and follows the per-query-file design. Main concerns: close() doesn't guard against exceptions mid-iteration (file handle leak), _clear_query_state silently omits _query_starts, and thread-safety (concurrent dict mutations + file writes without a lock) remains unaddressed from earlier review rounds.
daft/subscribers/abc.py Adds a non-abstract close() hook to the Subscriber base class with a sensible default no-op. Clean, minimal change with no issues.
tests/test_events.py Good unit and integration test coverage for the new subscriber. One test name (test_event_log_subscriber_writes_session_header) contradicts its own assertion. Missing tests for result_out, optimization, and execution callbacks individually, but these paths are exercised implicitly by the integration test.

Sequence Diagram

sequenceDiagram
    participant App
    participant Context as DaftContext
    participant Sub as EventLogSubscriber
    participant FS as FileSystem (per-query dir)

    App->>Context: enable_event_log(log_dir)
    Context->>Sub: __init__(log_dir)
    Context->>Context: attach_subscriber(_EVENT_LOG_ALIAS, sub)

    Note over App,FS: Query execution begins

    Context->>Sub: on_query_start(query_id, metadata)
    Sub->>FS: mkdir(log_dir/query_id)
    Sub->>FS: open events.jsonl (append, line-buffered)
    Sub->>FS: write event_log_started {daft_version}
    Sub->>FS: write query_started {}
    Sub->>FS: write plan_unoptimized {plan}

    Context->>Sub: on_optimization_start(query_id)
    Sub->>FS: write optimization_started {}

    Context->>Sub: on_optimization_end(query_id, optimized_plan)
    Sub->>FS: write optimization_ended {duration_ms}
    Sub->>FS: write plan_optimized {plan}

    Context->>Sub: on_exec_start(query_id, physical_plan)
    Sub->>FS: write execution_started {}
    Sub->>FS: write plan_physical {plan}

    loop Per operator (concurrent)
        Context->>Sub: on_exec_operator_start(query_id, node_id)
        Sub->>FS: write operator_started {node_id}
        Context->>Sub: on_exec_emit_stats(query_id, stats)
        Sub->>FS: write stats {node_id, metrics}
        Context->>Sub: on_exec_operator_end(query_id, node_id)
        Sub->>FS: write operator_ended {node_id, duration_ms}
    end

    Context->>Sub: on_result_out(query_id, partition)
    Sub->>FS: write result_out {rows}

    Context->>Sub: on_exec_end(query_id)
    Sub->>FS: write execution_ended {duration_ms}

    Context->>Sub: on_query_end(query_id, result)
    Sub->>FS: write query_ended {status, duration_ms}
    Sub->>FS: close events.jsonl

    App->>Context: disable_event_log()
    Context->>Context: detach_subscriber(_EVENT_LOG_ALIAS)
    Context->>Sub: close()
    Sub->>FS: close any remaining open file handles
Loading

Last reviewed commit: "log per query id"

return time.monotonic() * 1000


def _generate_run_id() -> str:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we made them a nice human readable id that shows up in the dashboard #6310. Would it be possible to reuse that so users can quickly correlate runs in the dashboard to these events.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's going to be tricky. Those names are associated with a query and a script can execute many queries. This means we would need to maintain and write multiple files. I thought for the first version it would be easier to write to a single file per script execution. Thoughts?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am of the opinion that it makes the most sense to have the query be the atomic unit as opposed to the script. @rchowell and I had a lengthy discussion about this the other day actually, and he may have some opinions on it as well. I think there's pros/cons of both sides but ultimately I think having everything 1-1 with the query (not script) will make things easier to reason about. Additionally, considering the dashboard uses queries as the atomic unti, it would be odd to me if we introduced an additional grouping here that does not exist there.

Happy to have further discussions as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My 2-cents is log to a file whose name is the query id.

Put it in some conventional location, then it's trivial to show logs in the dashboard for each query.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed with @universalmind303 offline and will go with a directory per query id to start. So the format will be <log_dir>/<query_id>/events.jsonl.

@cckellogg
Copy link
Contributor Author

@greptileai

Comment on lines +89 to +103
def _write_event(self, event_name: str, payload: dict[str, Any]) -> None:
if self._closed:
return
record: dict[str, Any] = {"event": event_name, "ts": _iso_now()}
record.update(payload)
try:
self._file.write(json.dumps(record, default=_json_default) + "\n")
except OSError:
pass # Don't let logging failures affect query execution

def close(self) -> None:
if self._closed:
return
self._closed = True
self._file.close()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Shared mutable state and file writes are not thread-safe

The sample log in the PR description shows concurrent operator_started events just 2ms apart (node IDs 1–4 at 23:33:54.901, .903, .905, .906), confirming that subscriber callbacks are invoked from multiple threads simultaneously.

The current implementation has several unprotected shared-state operations:

  • self._operator_starts[(query_id, node_id)] = _mono_ms() and self._operator_starts.pop(...) in on_exec_operator_start / on_exec_operator_end called concurrently from different threads
  • self._closed is checked and self._file.write(...) is called non-atomically in _write_event — a thread could pass the if self._closed: return guard just before another thread runs close(), then attempt a write on a closed file (though OSError is caught)
  • With Python 3.13+ free-threaded mode (--disable-gil) or non-CPython runtimes, individual dict operations are also no longer atomic

A threading.Lock should guard both the file write and any mutation of the timing-state dicts:

import threading

def __init__(self, log_dir: str | Path, run_id: str | None = None) -> None:
    ...
    self._lock = threading.Lock()
    ...

def _write_event(self, event_name: str, payload: dict[str, Any]) -> None:
    if self._closed:
        return
    record: dict[str, Any] = {"event": event_name, "ts": _iso_now()}
    record.update(payload)
    try:
        with self._lock:
            self._file.write(json.dumps(record, default=_json_default) + "\n")
    except OSError:
        pass

And similarly wrap mutations to _operator_starts / _exec_starts / etc. in the same lock (or a separate state lock).

@codecov
Copy link

codecov bot commented Mar 18, 2026

Codecov Report

❌ Patch coverage is 78.03468% with 38 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.80%. Comparing base (c0f5ed4) to head (5b0ffe0).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
daft/subscribers/events.py 78.36% 37 Missing ⚠️
daft/subscribers/abc.py 50.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #6420      +/-   ##
==========================================
+ Coverage   74.79%   74.80%   +0.01%     
==========================================
  Files        1021     1023       +2     
  Lines      136569   137193     +624     
==========================================
+ Hits       102142   102630     +488     
- Misses      34427    34563     +136     
Files with missing lines Coverage Δ
daft/subscribers/abc.py 71.05% <50.00%> (-1.17%) ⬇️
daft/subscribers/events.py 78.36% <78.36%> (ø)

... and 34 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@desmondcheongzx
Copy link
Collaborator

Cool! Looking at the log output, is the plan to add the operator multiline displays in the output too? E.g. "Filter: col(x) > 5", "Project: col(x) + 1, col(y) * 2"? Would be super useful

@cckellogg
Copy link
Contributor Author

cckellogg commented Mar 18, 2026

Cool! Looking at the log output, is the plan to add the operator multiline displays in the output too? E.g. "Filter: col(x) > 5", "Project: col(x) + 1, col(y) * 2"? Would be super useful

Yes, the plan is to add these types of things like this in additional PRs. The goal of this one is to output what we currently expose and then iterate.

@cckellogg
Copy link
Contributor Author

@greptileai

Comment on lines +120 to +126
def close(self) -> None:
if self._closed:
return
self._closed = True
for query_file in self._query_files.values():
query_file.close()
self._query_files.clear()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Exception in close() leaks remaining file handles

If query_file.close() raises an OSError for one of the open handles (e.g., a network-backed filesystem flushing buffered data), the for loop exits immediately and all remaining file handles in self._query_files are never closed. self._query_files.clear() is also never reached, leaving the dict populated with dead handles.

Wrapping each close in a try/except ensures the rest of the handles are always released:

Suggested change
def close(self) -> None:
if self._closed:
return
self._closed = True
for query_file in self._query_files.values():
query_file.close()
self._query_files.clear()
def close(self) -> None:
if self._closed:
return
self._closed = True
for query_file in self._query_files.values():
try:
query_file.close()
except OSError:
pass
self._query_files.clear()

Comment on lines +43 to +47
def test_event_log_subscriber_writes_session_header(tmp_path):
subscriber = EventLogSubscriber(tmp_path)
subscriber.close()

assert list(tmp_path.rglob("events.jsonl")) == []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test name contradicts its assertion

test_event_log_subscriber_writes_session_header implies that the test verifies a session header is written to disk. However the only assertion is assert list(tmp_path.rglob("events.jsonl")) == [] — i.e., it verifies that no file is created when the subscriber is closed without processing any queries. The name is the opposite of what the test actually checks, which will confuse future readers.

A clearer name would be something like test_event_log_subscriber_creates_no_files_on_close_without_queries.

Comment on lines +59 to +66
def _clear_query_state(self, query_id: str) -> None:
"""Remove any leftover timing state for the given query."""
self._optimization_starts.pop(query_id, None)
self._exec_starts.pop(query_id, None)

stale_operator_keys = [key for key in self._operator_starts if key[0] == query_id]
for key in stale_operator_keys:
self._operator_starts.pop(key, None)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _clear_query_state silently omits _query_starts

The docstring says "Remove any leftover timing state for the given query," yet _query_starts is not cleared here. It works today because every caller (on_query_end) pops from _query_starts before calling this method. However, the omission is a subtle footgun: if _clear_query_state is ever called from a new code path (e.g., a forced cleanup or a new on_query_cancel hook), the query-start timestamp will silently linger, causing the next on_query_end for a recycled query_id to compute a wildly wrong duration_ms.

Consider adding self._query_starts.pop(query_id, None) to _clear_query_state and removing the manual pop in on_query_end, so all timing cleanup is centralised in one place.

Copy link
Collaborator

@desmondcheongzx desmondcheongzx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Chris! I think the per-query directory approach is the right call

Overall looks good to me! Let's merge and keep iterating

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants