ZMQ subscriber service that consumes
EventRecordmessages from the pub/sub bus and persists them to JSONL or SQLite storage backends; runs as an independent subprocess.
The event logger is a ZMQ subscriber service that consumes EventRecord
messages from the pub/sub event bus and persists them to one or more storage
backends.
It runs as an independent subprocess with its own event loop, connected to the same ZMQ PUB socket as other subscriber services (e.g. metrics aggregator).
ZMQ PUB (ipc://)
│
┌──────────────┼──────────────┐
▼ ▼ ▼
EventLogger MetricsAggregator (future subscribers)
(JSONL/SQL) (real-time metrics)
event_logger/
├── __main__.py # CLI entry point + EventLoggerService
├── writer.py # RecordWriter ABC (flush interval support)
├── file_writer.py # JSONLWriter (msgspec-based JSONL output)
└── sql_writer.py # SQLWriter (SQLAlchemy, default sqlite)
The event logger subscribes to all topics (topics=None) so that every
published EventRecord is persisted. It does not interpret event semantics — it
writes records verbatim to all configured writers.
The only event type with special handling is SessionEventType.ENDED, which
triggers shutdown (see Lifecycle).
RecordWriter defines the interface for all writer backends:
class RecordWriter(ABC):
def write(self, record: EventRecord) -> None # calls _write_record + auto-flush
def _write_record(self, record: EventRecord) -> None # abstract
def flush(self) -> None
def close(self) -> None # abstractwrite() calls the subclass's _write_record(), increments a counter, and
auto-flushes when the counter reaches flush_interval. This keeps the flush
logic in one place regardless of backend.
The CLI maps writer names to classes via _WRITER_REGISTRY:
| Name | Class | Output File |
|---|---|---|
jsonl |
JSONLWriter |
events.jsonl |
sql |
SQLWriter |
events.db |
Multiple writers can be active simultaneously (e.g. --writers jsonl sql). Each
record is written to every configured writer.
JSONLWriter writes one JSON line per EventRecord using
msgspec.json.Encoder with a custom enc_hook that serializes EventType enum
members as their topic strings (e.g. "session.ended", "sample.complete").
Output path: {log_dir}/events.jsonl
Each line is a complete JSON object representing one EventRecord:
{
"event_type": "sample.issued",
"timestamp_ns": 1234567890,
"sample_uuid": "abc-123",
"data": null
}SQLWriter uses SQLAlchemy (default: sqlite) and maps each EventRecord to an
EventRowModel row:
| Column | Type | Content |
|---|---|---|
id |
Integer (PK) | Auto-increment |
sample_uuid |
String | Sample UUID |
event_type |
String | Topic string (e.g. "sample.complete") |
timestamp_ns |
BigInteger | Monotonic nanosecond timestamp |
data |
LargeBinary | msgspec.json.encode(record.data) (bytes) |
Output path: sqlite:///{log_dir}/events.db
The backend is swappable via the url parameter (e.g. PostgreSQL), though the
CLI currently only supports the default sqlite path.
uv run python -m inference_endpoint.async_utils.services.event_logger \
--log-dir /path/to/logs \
--socket-dir /path/to/socket_dir \
--socket-name ev_pub_abc123 \
--writers jsonl sql
- Parse CLI arguments.
- Create writer instances (one per
--writersentry), writing to{log_dir}/events.*. - Create
ManagedZMQContext.scoped(socket_dir=args.socket_dir)with the publisher's socket directory. - Create
EventLoggerService(extendsZmqEventRecordSubscriber), which connects to the publisher viactx.connect(socket, socket_name). - Call
logger.start()which registersadd_readeron the subscriber's event loop. await shutdown_event.wait()blocks until shutdown is signalled.
Each batch of records decoded from the ZMQ socket is passed to
process(records):
for record in records:
if _shutdown_received:
skip
if record is ENDED:
set _shutdown_received
write to all writers
if saw ENDED:
flush + close writers, request stop
All records up to and including SessionEventType.ENDED are written. All
records after ENDED (in the same or subsequent batches) are dropped regardless
of type.
When SessionEventType.ENDED is received:
- The ENDED record is written to all writers.
- All remaining records in the batch are dropped.
- Writers are flushed and closed; the writer list is cleared.
_request_stop()is scheduled on the event loop:- Removes the socket reader (via
close()) - Sets the
shutdown_eventso the process can exit
- Removes the socket reader (via
stateDiagram-v2
[*] --> Created: __init__
Created --> Listening: start
Listening --> Processing: _on_readable
Processing --> Listening: batch done, no ENDED
Listening --> ShuttingDown: ENDED in batch
Processing --> ShuttingDown: ENDED in batch
ShuttingDown --> [*]: writers closed, loop stopped
flowchart TB
subgraph Sub["EventLoggerService"]
A[ZMQ SUB socket]
B[_on_readable]
C[decode EventRecord]
D[process batch]
E{shutdown_received?}
F[write to all writers]
G{ENDED?}
H[set shutdown_received]
I[flush + close writers]
J[_request_stop]
A --> B --> C --> D --> E
E -->|no| F
E -->|yes| D
F --> G
G -->|no| D
G -->|yes| H --> F --> I --> J
end
subgraph Writers["Writers"]
W1[JSONLWriter]
W2[SQLWriter]
end
F --> W1
F --> W2
usage: uv run python -m inference_endpoint.async_utils.services.event_logger
--log-dir LOG_DIR
--socket-dir SOCKET_DIR
--socket-name SOCKET_NAME
[--writers WRITER [WRITER ...]]
| Argument | Required | Default | Description |
|---|---|---|---|
--log-dir |
Yes | — | Directory for log output files |
--socket-dir |
Yes | — | Directory containing ZMQ IPC sockets (must already exist) |
--socket-name |
Yes | — | Socket name within socket-dir |
--writers |
No | jsonl |
Writer backends: jsonl, sql, or both |
publish(EventRecord(...)) calls are connected in the load generator
(BenchmarkSession publishes STARTED / ISSUED / COMPLETE / ERROR events via its
EventPublisher; the prompt rides on the ISSUED event's PromptData payload
rather than a separate event), so the event logger receives and persists all
session/sample/error events published during a benchmark run.