NAME

Punk::Observe::Live - the log tail, and what it admits losing

SYNOPSIS

use Punk::Observe::Live;

my $topic = Punk::Observe::Live::topic('acme');   # po.tail.acme

my $rec = Punk::Observe::Live::roundtrip({
    t => '1774224000000000000', stream => '7', severity => 17,
    service => 'api', body => $line,
});
warn "line was cut\n" if $rec->{truncated};

DESCRIPTION

A browser tailing logs is connected to one worker. The lines it wants are being ingested by all of them, so an ingesting worker publishes matching records on a per-tenant topic and whichever worker holds the connection forwards them.

Across a pool that is Hyperman's shared-memory bus, and it is optional: the self-hosted default is one worker, where a tail never leaves the process it was ingested in. "have_bus" reports which this build got.

The transport refuses oversize; this does not

The bus returns an error for a message larger than its slot rather than shortening one. Publishing a long log line unchanged therefore produces no line at all, and the tail would silently skip exactly the interesting ones.

So the record is cut here, deliberately, and the flag travels with it. A truncation the reader can see is a different thing from a line that never arrived.

Everything lost carries a number

A lapped consumer, a reconnection whose last event id has scrolled out of the buffer, and a client that stopped reading are each reported with a count. A silently short stream is indistinguishable from a quiet one.

FUNCTIONS

have_bus

my $bool = Punk::Observe::Live::have_bus();

Whether this build found hm_bus.h and can carry a tail across workers. False means an in-process tail, which is correct for one worker and wrong for a pool.

slot_sizes

my ($ours, $bus) = Punk::Observe::Live::slot_sizes();

The slot size records are encoded against, and the bus's own - zero where there is no bus. They must agree: a record sized against the wrong constant is refused rather than truncated, which is the failure this whole module exists to avoid.

topic

my $topic = Punk::Observe::Live::topic($tenant);

The publish topic for a tenant, or undef where the tenant id is not [A-Za-z0-9_-]{1,64}. The tenant is part of the topic because a tail is a query and carries one; a topic accepting arbitrary bytes is a way to subscribe to another tenant's stream.

roundtrip

my $r = Punk::Observe::Live::roundtrip(\%rec);

Encodes a record for a slot and decodes it straight back. Takes t, stream, severity, service and body; returns those fields as they survived, plus:

encoded_len  bytes on the wire
fits_slot    true when the transport will accept it
truncated    true when anything was cut
flagged      true when the DECODED record says so

truncated and flagged are separate on purpose: the first is what the encoder did, the second is what a reader on the far side can see. They must agree.

decode_bad

my $ok = Punk::Observe::Live::decode_bad($bytes);

Runs the decoder over arbitrary bytes and reports whether it accepted them. A slot is untrusted input the moment another process wrote it, and a length field claiming more than the slot holds must be refused rather than followed.

ring

my $r = Punk::Observe::Live::ring({ cap => 512, bytes => '524288',
                                    rows => \@rows, since => $last_id });

The resume buffer that backs Last-Event-ID. Pushes every row, then reports what follows since:

rows     [ { id => '7', data => '...' }, ... ]
missed   how many were lost between `since` and the oldest held
evicted  how many scrolled off the back in total
held     how many are held now
oldest   the oldest id still held
bytes    what they occupy

missed is the point. A reconnection that quietly restarts from the oldest available row hides a gap; this one is told the size of it. since of 0 is a fresh connection, which missed nothing because it had seen nothing.

The buffer is bounded by rows and by bytes, because the row bound alone would let one connection hold a megabyte for a resume nobody may ever ask for.

flow

my $r = Punk::Observe::Live::flow($limit, \@sizes, $drain_each);

Backpressure. Admits rows until the unread total would exceed $limit, then closes. Returns admitted, refused, pending and closed.

A browser that has stopped reading must not become an unbounded queue in the server, so the connection is closed with a reason rather than the worker growing a buffer until something unrelated fails.

sse

my $frame = Punk::Observe::Live::sse($id, $event, $data);

One SSE frame. The id is never omitted, because it is what makes a resume possible at all, and a body containing a newline is split across data: lines - otherwise the frame ends early and the rest of the line becomes the next event.

heartbeat

my $frame = Punk::Observe::Live::heartbeat();

A bare comment frame, sent on an idle stream so an intermediary does not close it. It carries no id: a heartbeat that advanced Last-Event-ID would make a reconnection resume past real rows.

bus_init

my $ok = Punk::Observe::Live::bus_init($slots);

Brings up the shared bus arena. Called before the fork: a subscription made afterwards lands in one worker, and a cursor that starts at "now" silently misses everything published before it.

bus_publish

my $rc = Punk::Observe::Live::bus_publish($topic, $payload);

Publishes one record. A negative return is a refusal - oversize, or no bus in this build.

bus_drain

my $d = Punk::Observe::Live::bus_drain($topic);

Everything published on $topic since the last call, as { count, gaps, rows }. Records on other topics are not returned; forwarding another tenant's topic would be the tenancy boundary failing open.

gaps counts what this consumer was lapped past. Reporting it is not optional.

bus_reset_cursors

Punk::Observe::Live::bus_reset_cursors();

What a post-fork hook calls. A cursor is a position in a stream this process has not been reading, so an inherited one either replays what the parent handled or skips what it has not - and both look like a broken tail rather than a fork bug. The reset starts at the current sequence, never at zero.

SEE ALSO

Punk::Observe, Punk::Observe::Log, Punk::Observe::Query