honest framework

honest-observe

One append-only event log for browser, server, and database. Every function is already its own print statement.

One append-only event log for the entire stack. State is a projection over events, queryable at any point in time.
What you do now
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.
What honest-observe does
# 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
What you do now
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.
What honest-observe does
# 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
What you do now
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.
What honest-observe does
// 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
What you do now
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.
What honest-observe does
// 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
The conventional pattern
# 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.
The honest pattern
# 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.

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:

  1. Validates the session (auth layer)
  2. Stamps the event with received_at server timestamp
  3. Appends to honest_event_log
  4. 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

RequirementTest
Every event has all required envelope fieldsValidate envelope schema
event_id is UUID v7 (time-ordered)Check format and ordering
sequence is monotonically increasing per aggregate_idInsert 3 events, verify
emit() only called inside boundary linkshonest-check HC-P004
hf.request.canonical is the last event emitted per requestCheck event log ordering
Browser events carry source: "browser"Inspect beacon payload
request_id joins browser events to server canonical eventQuery by request_id, verify both present
/api/observe/ingest returns 204, never blocksLoad test with sendBeacon
Append-only table rejects UPDATE/DELETETest both operations, verify server fault
Fold function receives events in timestamp orderVerify projection fold order
sql field absent from hf.persist.query in production modeDeploy to prod, check log

Reference