honest-observe
One append-only event log for browser, server, and database. Every function is already its own print statement.
import logging
logger = logging.getLogger(__name__)
async def create_order(request):
logger.info(f"create_order called: {request}")
try:
result = await run_pipeline(request)
logger.info(f"create_order ok: {result}")
return result
except Exception as e:
logger.error(f"create_order failed: {e}")
raise
# Manual. Copy-pasted. Misses timing, link sequence,
# query count, browser context. No browser/server join.
# No logging code anywhere in your application.
# @link and @catch_at_boundary emit automatically:
# hf.chain.started chain=create_order
# hf.link.executed link=validate_order ok 0.4ms
# hf.link.executed link=check_inventory ok 0.8ms
# hf.persist.query SELECT inventory 3ms rows:1
# hf.link.executed link=write_order ok 12ms boundary
# hf.chain.completed create_order ok 14ms
# hf.request.canonical POST /orders 200 14ms req=req_abc
# hf.browser.response 200 163ms req=req_abc ← joined
class OrdersController < ApplicationController
def create
Rails.logger.info "OrdersController#create params=#{params}"
@order = Order.create!(order_params)
Rails.logger.info "Order created id=#{@order.id}"
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "Order failed: #{e}"
render json: { error: e.message }, status: 422
end
end
# Manual. Misses timing, query count, browser origin.
# No logging code. Events emitted automatically:
#
# hf.chain.started chain=create_order_pipeline
# hf.link.executed link=validate_order ok 0.4ms
# hf.link.executed link=persist_order ok 12ms boundary
# hf.persist.query INSERT orders 11ms
# hf.chain.completed create_order_pipeline ok 14ms
# hf.request.canonical POST /orders 201 14ms req=req_abc
# hf.browser.response 201 180ms req=req_abc ← browser joined
func CreateOrder(ctx context.Context, req Request) (Order, error) {
slog.Info("CreateOrder start", "req", req)
order, err := runPipeline(ctx, req)
if err != nil {
slog.Error("CreateOrder failed", "err", err)
return Order{}, err
}
slog.Info("CreateOrder ok", "order", order.ID)
return order, nil
}
// Manual. No timing. No query count. No browser join.
// No logging code. Events emitted automatically:
//
// hf.chain.started chain=create_order
// hf.link.executed link=ValidateOrder ok 0.4ms
// hf.link.executed link=PersistOrder ok 12ms boundary
// hf.persist.query INSERT orders 11ms
// hf.chain.completed create_order ok 14ms
// hf.request.canonical POST /orders 201 14ms req=req_abc
// hf.browser.response 201 170ms req=req_abc ← browser joined
class OrderController extends Controller {
public function store(Request $request) {
Log::info('OrderController::store', $request->all());
try {
$order = Order::create($request->validated());
Log::info('Order created', ['id' => $order->id]);
return response()->json($order, 201);
} catch (\Exception $e) {
Log::error('Order failed', ['error' => $e->getMessage()]);
}
}
}
// Manual. No timing. No query count. No browser context.
// No logging code. Events emitted automatically:
//
// hf.chain.started chain=create_order_pipeline
// hf.link.executed link=validate_order ok 0.4ms
// hf.link.executed link=persist_order ok 12ms boundary
// hf.persist.query INSERT orders 11ms
// hf.chain.completed create_order_pipeline ok 14ms
// hf.request.canonical POST /orders 201 14ms req=req_abc
// hf.browser.response 201 175ms req=req_abc ← browser joined
# Manual logging at every function entry and exit.
# No timing unless you write it.
# No query count unless you instrument it.
# Browser and server logs are in separate systems.
# Joining them requires a correlation ID you set up yourself.
# State is reconstructed after the fact from incomplete data.
# Zero logging code. @link emits hf.link.executed automatically.
# @catch_at_boundary emits hf.request.canonical at request end.
# honest-persist emits hf.persist.query from inside execute().
# Browser beacons hf.browser.response via sendBeacon().
# All joined by request_id. One log. Three layers.
# honest-observe tail — that's all it takes to see everything.
The concept
You have a function that handles a checkout. You want to know: did it run? How long did it take? Did the database query succeed? You add a print statement, then a logger.info, then a Sentry capture, then a custom metric. By the end you have eight lines of instrumentation for two lines of business logic — and none of it tells you what the browser did before the request arrived.
honest-observe takes a different position: the framework instruments everything by default. You write no logging code. The @link decorator, chain(), classify(), and honest-persist all write to a shared append-only log as a side effect of normal execution. The first time you look at the log, everything is already there.
What gets recorded, and where
Events come from three places, all automatic:
Frontend. The browser sends events via sendBeacon() to /api/observe/ingest — every attribute classification, DOM state change, HTMX request, and HTMX response. Fire-and-forget: it never blocks the page.
Server. @catch_at_boundary wraps every request handler. When the request completes, it emits one event with everything in it: HTTP method, status code, chain name, which links ran, how many queries executed, total duration, any fault codes. One event per request, not ten.
Database. honest-persist writes to the log from inside execute() and apply(). Every query and migration is recorded. You did not ask for this; it happens because the query ran.
The join key
Every event carries a request_id. A browser click generates a request_id. That same request_id appears in the server event for the HTMX call it triggered, and in the database events for the queries that ran during it. To see the complete trace of one user interaction — browser through server through database — filter by request_id. One key, everything.
Projections
You do not query the log directly. You write a projection: a pure function that folds events into a read model.
def project_login_trends(days=30):
def fold(state, event):
date = event.timestamp[:10]
state.setdefault(date, {"successful": 0, "failed": 0})
key = "successful" if event.payload["result"] == "success" else "failed"
state[date][key] += 1
return state
return project(
event_types = ["app.auth.login_attempted"],
from = now() - timedelta(days=days),
fold = fold,
initial_state = {},
)
A new question about the system requires a new projection function. It does not require a schema migration, a new database table, or a code change to the path that generates the events. Replay the log against the new projection and the answer exists, including for events that happened before you thought to ask.
Development
honest-observe tail streams the log to the terminal — browser and server events interleaved by timestamp. honest-observe inspect <request_id> renders the full execution tree for one request: DOM state change, chain execution, each link, each query, the browser response, with timing at every step.
In development, link events include the full input and output manifest. In production they are omitted. One config flag controls it.
The abstract principle
An event log is an append-only record of facts. Facts do not change. They accumulate.
This is the core principle of event sourcing, and it has a formal consequence: state is a projection over events, not a stored value. The current state of anything is derived by reading the log from the beginning (or from a snapshot) and folding the events. The log is the truth. The current state is a view.
Why append-only is a formal property
A mutable database stores the current state. You write a record; you update it; you delete it. The history of what happened is lost unless you add audit tables, updated_at columns, and archiving logic — all of which are afterthoughts bolted onto a model that was not designed for history.
An append-only log stores every fact that ever occurred. You cannot update an event. You cannot delete one. Every state change is a new event. History is structural: it is not a separate system; it is the same system read from the beginning.
The formal property is monotonicity: the log only grows. A projection over a monotonic log is a fold — a pure function over an ordered sequence of events. Because the log never changes, the same fold over the same prefix always produces the same result. This is referential transparency applied to observability.
Every function is already its own print statement
In a conventional application, a developer adds log.info() calls to record what functions are doing. These calls are manual, inconsistent, and typically incomplete. They record what the developer thought was important at the time they wrote the code. They miss timing, query counts, browser events, and every fact the developer did not anticipate needing.
honest-observe instruments from the outside. The @link decorator records the function's name, its input and output manifest slots, its duration, and whether it succeeded. It does this automatically, for every link, with no developer code. The function itself is unchanged. The fact that it executed with these inputs and produced this output is in the log before the developer knows they need it.
This is the same insight as a tracing compiler: the program has all the information needed to describe its own execution. The question is whether the infrastructure captures it automatically or requires the developer to repeat it manually.
One log for browser and server
The browser and server are not separate systems. They are two halves of one user interaction. A request begins when the user acts in the browser. It ends when the browser renders the server's response. Separating browser events into one monitoring system and server events into another makes incident response dependent on joining two separate logs that were not designed to join.
honest-observe uses request_id as the join key. The server generates it; the browser receives it in the response header and attaches it to every subsequent browser event until the next response. The entire interaction — DOM state change, HTMX request, classification, chain execution, database query, canonical summary, browser response, DOM update — is joinable by one key in one log.
This is not a novel idea. Stripe's canonical log line and Google's Dapper distributed tracing both use this pattern. honest-observe applies it as a default, not as an advanced feature.
Projections as the read model
project(fold, initial_state, events) is a pure function. Same events, same fold, same result. The projection is a derived view of the log — a read model computed on demand or cached as a snapshot.
This separation between write model (the log) and read model (the projection) is the CQRS pattern from domain-driven design. honest-observe makes it structural: all reads go through projections, all writes go through emit(). The log and the projections are different physical structures with different update semantics. The log is append-only and immutable. The projections are computed and replaceable.
Full specification
The Event Envelope
Every event in the log uses the same structure regardless of source (browser, server, database):
{
event_id: UUID v7 (time-ordered)
event_type: dot-namespaced string, e.g. "hf.chain.completed"
event_version: schema version string, e.g. "1.0"
timestamp: ISO 8601 with microsecond precision, UTC
sequence: integer, monotonically increasing per aggregate_id
aggregate_type: string — what kind of thing this event is about
aggregate_id: string — which specific one
payload: dict — event-type-specific data
auth: dict — owned by the auth layer, carried verbatim
meta: dict — optional application metadata
}
The auth partition is never read by honest-observe for any purpose other than storing and forwarding. Its schema is owned by the authentication layer in use.
Framework Events (Auto-Emitted)
These events are emitted automatically by the framework. Zero developer code required.
hf.chain.started / hf.chain.completed
aggregate_type: "chain"
payload: { chain_name, link_count, duration_ns, result, fault_code? }
hf.link.executed
aggregate_type: "link"
payload: {
link_name, chain_name, duration_ns, result, fault_code?,
boundary, mutations, singletons, nondeterminism, io_calls
}
mutations is the count of manifest field mutations detected. Any non-zero value is reported to the honesty violation threshold projection.
hf.persist.query
aggregate_type: "persist"
payload: { db_id, table_name, operation, row_count, duration_ns, sql_hash, request_id? }
sql (full SQL string) is included only in development mode.
hf.request.canonical — emitted last, by @catch_at_boundary:
aggregate_type: "request"
payload: {
http_method, http_path, http_status,
chain_name?, link_count, link_sequence,
token_count, rejection_count,
query_count, query_duration_ns,
result, fault_code?, fault_category?,
duration_ns, request_id, source: "server"
}
This is the zero-join incident response record. One event contains every meaningful fact about the request.
The emit() Contract
FUNCTION emit(event_type, aggregate_type, aggregate_id, payload, context):
event ← build_envelope(event_type, aggregate_type, aggregate_id, payload, context)
result ← honest_persist.append("honest_event_log", event)
IF "err" IN result:
RETURN err({ code: "emit_failed", ... })
RETURN ok({ event_id: event.event_id })
emit() must be called inside a link declared boundary=True. Calling emit() in a non-boundary link is HC-P004. honest-check enforces this.
The Projection Interface
FUNCTION project(event_types, aggregate_type?, aggregate_id?, from?, to?, fold, initial_state):
events ← query_log(event_types, aggregate_type, aggregate_id, from, to)
state ← initial_state
FOR EACH event IN events ORDER BY timestamp:
state ← fold(state, event)
RETURN state
The fold function is a pure function: (state, event) → state. No I/O inside the fold. No mutation of state — return a new value.
Sequence Numbers
Sequence numbers are per-aggregate, monotonically increasing. Two events with the same aggregate_id will have different sequence numbers. Sequence numbers are generated by honest-persist using a per-aggregate counter. They provide ordering guarantees within an aggregate without global locks.
Browser Event Ingest
Browser events are sent to /api/observe/ingest via sendBeacon(). The ingest endpoint:
- Validates the session (auth layer)
- Stamps the event with
received_atserver timestamp - Appends to
honest_event_log - Returns 204 No Content
The endpoint never blocks. All writes are asynchronous.
Browser events carry source: "browser" and a request_id that joins them to the server's hf.request.canonical event for the same user interaction.
The Append-Only Table Contract
The event log table is declared append_only=True in honest-persist. UPDATE and DELETE against this table produce server faults immediately. This is enforced at the honest-persist level, not by database constraints.
Threshold Projections
A threshold projection watches a built-in metric and fires an alert when a condition is crossed:
{
"projection_id": string,
"metric": string, # built-in metric name
"condition": { "operator": ">"|"<"|">="|"<=", "value": number },
"window": string, # "1m"|"5m"|"1h"|"24h"
"cooldown": string, # minimum time between firings
"alert": { "message_type": string, "recipient": ActorRef, "dom_surface": string? },
"remediation": string?, # chain name to run on affirmative reply
"enabled": bool,
}
Threshold projections are stored as honest-persist records. They can be toggled and adjusted at runtime without code changes.
Conformance Requirements
| Requirement | Test |
|---|---|
| Every event has all required envelope fields | Validate envelope schema |
event_id is UUID v7 (time-ordered) | Check format and ordering |
sequence is monotonically increasing per aggregate_id | Insert 3 events, verify |
emit() only called inside boundary links | honest-check HC-P004 |
hf.request.canonical is the last event emitted per request | Check event log ordering |
Browser events carry source: "browser" | Inspect beacon payload |
request_id joins browser events to server canonical event | Query by request_id, verify both present |
/api/observe/ingest returns 204, never blocks | Load test with sendBeacon |
| Append-only table rejects UPDATE/DELETE | Test both operations, verify server fault |
| Fold function receives events in timestamp order | Verify projection fold order |
sql field absent from hf.persist.query in production mode | Deploy to prod, check log |
Reference
- honest-observe Packagist PHP implementation (coming)
- honest-observe-architecture.md Full spec