feat(logs): unify live tails and serve retained history#1487

merged29
obs/2-harvest
obs/3-serve
openedOct 9, 2026byNNick
View on GitHubGitHub import enabledPR last imported 21d ago

Summary

The release has two log read RPCs. TailLogs combines live Container output and Machine-service diagnostics. LogHistory returns bounded retained pages. ForgetLogs and its teardown fanouts are removed, so deleted Environment logs age out under normal retention.

mermaid
1flowchart LR
2 Clients[CLI and complete native and JavaScript SDK] --> Tail[TailLogs]
3 Tail --> Live[Docker and journald]
4 Clients --> History[LogHistory]
5 History --> Observer[Observer with 10% CPU quota]
6 Observer --> Store[Machine Log Store]

Third of four native stacked PRs. Depends on #1463. This layer includes the complete SDK transport, history paging and matching Cloud caller migration. #1484 completes viewer behavior and recovery.

Evidence

  • Current published-head CI passes. The independent source reconciliation and final runtime receipt audits pass with notes.
  • Contract and capabilities pass 51 tests. Original CLI suites pass. Final all-feature daemon library suite passes 541 tests with 17 ignored after reconciling current main. Daemon binary tests pass 161. The event-owned rescan fixture now requires unknown exit status and one missing-exit gap, matching the inspect-only exit contract.
  • Six native log-reader tests pass. Before the fix, a silent open history stream never completed; it now returns Unavailable after ten seconds without a frame. Heartbeats renew that deadline, cancellation preserves the Session, and quiet live tails remain unbounded. Twenty connection tests pass. Matching Cloud callers pass typecheck and nine focused tests in this layer.
  • Thirteen Node log tests pass, including duplicate records across pages and partial failure from a stalled reader. SDK types, strict workspace Clippy and formatting pass. Independent native and JavaScript source reviews pass.
  • Fresh release-Machine proof retains all 500 unique lines after daemon restart and Container removal, records the missing-exit diagnostic and confirms the 10% quota. Corrosion, DNS and the observer retain their identities across daemon stop/start. Live Container output, cancellation and all three Machine-service targets succeed with the observer stopped.
  • The original disposable Machine returned actual Container and Ployz, Docker and Corrosion rows with the observer stopped. SDK cancellation and all 500 retained lines passed before and after actual Environment deletion.

Merge Danger

Door: two-way.

Blast Radius: protocol.

The operator approved the DESIGN exception requiring matching clients and daemons, including for live logs. Old 0.2.3 log RPCs are incompatible. Namespace and Service name reuse can expose earlier retained output. There is no legacy fallback. Observer CPU stays capped at 10% of one core; main-daemon serving CPU is separate. The operator accepted slower historical reads, including the four-second case.

Checks

14 successful ยท 1 skipped
All checks passed
752a3b6
Synced from GitHub

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
๐Ÿ“ Code Reviewโœ… Completed 2026-10-10T11:01:12.704004Z752a3b6New commits
โ„น๏ธ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with ๐Ÿ‘€ while any review is running, comments if it has suggestions, and reacts with ๐Ÿ‘ once all reviews finish with no findings.

Cchatgpt-codex-connectorBotreviewed ยท 3 inline threadsOct 10, 2026
Synced from GitHub

๐Ÿ’ก Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4496143f67

โ„น๏ธ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with ๐Ÿ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

core/crates/ployz-core/src/rpc_catalog.rsline 99Outdated ยท 4496143Open thread
โ€ฆโ€ฆ@@ -92,10 +92,11 @@ macro_rules! rpc_catalog {
9697 }
9798 server_streaming {
98- ContainerLogHistory: (container_log_history, "ContainerLogHistory", ContainerLogHistoryRequest, "container_log_history", CONTAINER_LOG_HISTORY_CAPABILITY, "ployz.container.log-history.v1", Container),
99+ LogHistory: (log_history, "LogHistory", LogHistoryRequest, "log_history", LOG_HISTORY_CAPABILITY, "ployz.logs.history.v2", Container),

P1 Badge Keep the v1 history RPC while adding v2

An existing Cloud/SDK client compiled against ContainerLogHistory will receive Unimplemented as soon as its daemon is upgraded because this replaces the method and removes the advertised ployz.container.log-history.v1 capability; conversely, the new client cannot query an older daemon. Machine RPC is covered by the 0.x stable promise in core/DESIGN.md, so introduce LogHistory alongside the v1 operation rather than replacing it within protocol major 1.

AGENTS.md reference: AGENTS.md:L5-L5

Useful? React with ๐Ÿ‘ย / ๐Ÿ‘Ž.

The operator explicitly approved replacing the old log contract with TailLogs and LogHistory while the user base is small. core/DESIGN.md documents that matching clients and daemons are required. No legacy history RPC or fallback is retained, as approved.

core/crates/ployz-core/src/rpc.rslines 359โ€“362Outdated ยท 4496143Open thread
โ€ฆโ€ฆ@@ -356,8 +356,10 @@ pub struct RemoveContainerRequest {
360+ /// earlier output of that second.
359361 #[serde(default)]
360- pub since_unix_seconds: Option<i64>,
362+ pub since_nanos: Option<i64>,
Cchatgpt-codex-connectorBoton lines 359โ€“362Oct 10, 2026

P1 Badge Preserve the existing log-window wire field

During a supported mixed-version upgrade, an older CLI sends since_unix_seconds to this daemon; serde ignores that now-unknown field and defaults since_nanos to None, so --since silently returns output from before the requested boundary. The reverse pairing similarly loses the bound because an older daemon ignores since_nanos. This renames a frozen Machine RPC field without changing PROTOCOL_MAJOR, contrary to the additive compatibility promise in core/DESIGN.md; retain the old field or add an additive representation that both versions can interpret.

AGENTS.md reference: AGENTS.md:L5-L5

Useful? React with ๐Ÿ‘ย / ๐Ÿ‘Ž.

The operator explicitly approved requiring matching new clients and daemons for the two-RPC simplification. core/DESIGN.md now records the stable-promise exception for this release. TailLogs replaces both old live methods, so supported mixed-version log reads are intentionally excluded. Retaining the old field would preserve a compatibility path the operator declined.

core/crates/ployz/src/sdk/logs.rsline 23Outdated ยท 4496143Open thread
โ€ฆโ€ฆ@@ -11,18 +12,73 @@ use ts_rs::TS;
2021 pub tail: i32,
2122 pub follow: bool,
22- pub before_nanos: Option<String>,
2323 pub since_unix_seconds: Option<i64>,

P1 Badge Update the JavaScript runtime-log transport

@ployz/sdk still routes both Client.runtime.logs() and Client.runtime.logHistory() through runtime-logs.js:27, whose input always includes before_nanos, and index.js:46 deserializes that object into this #[serde(deny_unknown_fields)] type. Removing the field therefore rejects even ordinary live-log requests where before_nanos is null, while history continues calling the obsolete container-log path; update and verify the JavaScript/native transport in this change or retain input compatibility.

AGENTS.md reference: core/AGENTS.md:L22-L22

Useful? React with ๐Ÿ‘ย / ๐Ÿ‘Ž.

Fixed in c23056e19. The complete JavaScript and native SDK transport now belongs to this PR, including runtime.logHistory and its public declarations. Thirteen Node log tests, SDK types and native transport checks pass. OBS-4 no longer owns the transport migration.

Added 27 commits

  • Remove redundant SDK implementation comments0daabf1
  • Keep native and JavaScript log transports together131ce40
  • Forget an Environment's logs when its removal applies1c53dfa
  • Test the running snapshot's stale exit and one inspect at a time21193b4
  • Count a run as new only when it started after the saved run finished430b58d
  • fix(logs): bound native history frame waits4ae36e4
  • Select a namespace in history reader regression fixtures4b2a448
  • Avoid full bounds windows for ordinary log frames4b2aef7
  • Migrate Cloud callers with the history SDK contract4fcebac
  • Expect an unknown exit when rescan only saw Docker events5f7af58
  • Avoid per-row gzip on history reads627ec83
  • Keep a crash-looping container's exit: a restarting container has exited, and removal keeps the last die6785db7
  • Accept days in log times, so --since 30d works698487c
  • Only a newer start ends the saved run, since Docker stamps a die after its exit738aecb
  • Keep a run's exit when Docker sends its oom after its die7c21a7e
  • Follow each container from its own last history line, and list failed Servers in --json7c72b88
  • Serve log history from each Machine's store to ployz logs8e16963
  • Replace ContainerLogs and MachineLogs with TailLogs and drop ForgetLogsa182d1e
  • Take a run's exit from inspect aloneaaf18d3
  • Take a container's run boundary from its event orderaff9ece
  • Give a full page a cursor past skipped files, hold back rows from a line still being written, and keep a page's reader until it is writtenc1a870b
  • docs(logs): record approved RPC and retention changesc6ee7fc
  • Let a restart end the previous run's exit and OOM killcedc333
  • test(logs): cover native history reader silence and cancellationcfbbac8
  • Bound history pages by bytes and readers, cache file bounds, hold back unfinished lines, follow unprinted containers from history's startd16e5c9
  • Let a line still being written hide nothing: it takes its last chunk's time, and removal keeps a die newer than the saved exitd321eec
  • Read qualified service history without Docker discoveryd7461da
Cchatgpt-codex-connectorBotreviewed ยท 3 inline threadsOct 10, 2026
Synced from GitHub

๐Ÿ’ก Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5f7af583cf

โ„น๏ธ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with ๐Ÿ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

docs/user/observe/logs.mdline 37Outdated ยท 5f7af58Open thread
โ€ฆโ€ฆ@@ -32,6 +32,17 @@ new containers printed, including the pre-deploy command. See
3434
35+## Read retained logs from the CLI
36+
37+`ployz logs app/api` reads retained logs without asking Docker to list containers.

P2 Badge Correct the retained-log lifecycle documentation

This new section says removed-service logs are retained, but the same page still states that replacing a container deletes its logs and leaves an older Deploymentโ€™s Deploy tab empty. The new Log Store and deployment-filtered history now retain and serve those records, so users receive contradictory guidance about the behavior introduced here; update the lifecycle bullet as part of this user-visible change.

AGENTS.md reference: AGENTS.md:L6-L6

Useful? React with ๐Ÿ‘ย / ๐Ÿ‘Ž.

Fixed later in this stack, in #1484. The "A deploy clears the old logs" bullet is gone. docs/user/observe/logs.md now says deleted environments and removed services keep their logs until retention removes them.

core/crates/ployz/src/handlers/operator.rsline 742Outdated ยท 5f7af58Open thread
โ€ฆโ€ฆ@@ -459,58 +568,222 @@ fn parse_fanout_selectors(values: Vec<String>) -> Result<Vec<FanoutSelector>, Er
739+
740+impl Printed {
741+ fn record(&mut self, record: &HistoryRecord) {
742+ if let HistoryEvent::Line { ts, .. } | HistoryEvent::Exit { ts, .. } = record.event {

P2 Badge Exclude exit events from printed-line coverage

When a followed history window contains an exit event, this records the exit timestamp as though an output line had been printed. For example, logs -n 1 -f on an exited or restarting container can return only the newest exit row; the subsequent Docker tail starts at that exit time and Printed::covers suppresses all earlier container output, so the requested log line is never shown. Only HistoryEvent::Line should advance the per-container output boundary.

Useful? React with ๐Ÿ‘ย / ๐Ÿ‘Ž.

Not changing this. since() returns the first printed timestamp, so the live read starts there no matter what. covers() hides a line only if it is at or before what was printed for that container. A line after an exit is newer and still prints. Counting the exit only stops older lines from coming back, and those fall outside the -n window that Docker applies anyway.

core/crates/ployz/src/deploy/exec/mod.rslines 1099โ€“1100Outdated ยท 5f7af58Open thread
โ€ฆโ€ฆ@@ -1087,6 +1078,30 @@ async fn compensate<C: MachineOperations>(
1097+ result => result.map_err(|error| machine_error(MachineAction::StopContainer, error))?,
1098+ }
1099+ let _ = client
1100+ .remove_container(&operation.machine_id, container_id)
Cchatgpt-codex-connectorBoton lines 1099โ€“1100Oct 10, 2026

P1 Badge Preserve failed-container logs before removing it

When replacement health or serving checks fail, compensation now removes the new container before apply calls Direct::tails. That diagnostic path still uses TailLogs, whose daemon handler first inspects the Docker container, so the removal makes it return NotFound and log_tail silently produces an empty tail. Read the retained history for this diagnostic or defer removal until after the failure report has captured the output.

Useful? React with ๐Ÿ‘ย / ๐Ÿ‘Ž.

Fixed in 57472e338. The failure tail is now read from the Log Store (LogHistory for that container, newest first) instead of TailLogs, so a container that compensation removed still reports its last 10 lines. The CLI report and the Store runner share one helper now. The test failed_deploy_whose_container_is_removed_still_reports_its_output_lines fails without the fix and passes with it.

Added 1 commit

  • Read a failed container's last lines from the Log Store57472e3
Cchatgpt-codex-connectorBotreviewed ยท 2 inline threadsOct 10, 2026
Synced from GitHub

๐Ÿ’ก Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 57472e338f

โ„น๏ธ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with ๐Ÿ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

dashboard/src/modules/runtime/container-logs.server.tsline 20Outdated ยท 57472e3Open thread
โ€ฆโ€ฆ@@ -15,7 +15,9 @@ export const logSearchSchema = Schema.Struct({
18- before: Schema.optional(Schema.fromJsonString(Schema.Record(Schema.String, Schema.String.check(Schema.isPattern(/^-?\d{1,19}$/))))),
18+ /** Read the Log Store instead of following: its newest page, or the one behind `cursor`. */
19+ history: Schema.optional(Schema.Literal("1")),
20+ cursor: Schema.optional(Schema.String.check(Schema.isMaxLength(4096))),

P2 Badge Allow fanout cursors past 4 KiB

In the dashboard history flow, the SDK cursor serializes one state entry per Server, so a page where roughly 25 Servers each have more history already produces a cursor over 4 KiB; the next /api/runtime/logs request is then rejected as an invalid selection and scrollback stops. This is reachable because the product explicitly supports unlimited Servers (docs/user/account/organizations.md:30), so the cursor must be compressed/transported differently or this validation limit must accommodate the supported fanout.

AGENTS.md reference: AGENTS.md:L6-L6

Useful? React with ๐Ÿ‘ย / ๐Ÿ‘Ž.

Fixed in 752a3b620. Each Server adds about 180 base64 characters to the cursor, so the 4 KiB cap broke at roughly 20 Servers, and raising it would only hit the URL limit next. History reads now POST, with the cursor in a JSON body capped at 256 KiB. GET serves only the live stream. A new test decodes a 50-Server cursor; it fails at the old cap and passes now.

core/crates/ployz-sdk/runtime-logs.jslines 177โ€“178Outdated ยท 57472e3Open thread
โ€ฆโ€ฆ@@ -116,32 +116,121 @@ async function* logs(transport, options = {}) {
145- return { records: all, errors: results.flatMap(result => result.errors) };
176+ const page = { records: [], gaps: [], exits: [], failures: [], cursor: null };
177+ const chosen = selector(filter, view.containers);
178+ if (!chosen) return page;
Cchatgpt-codex-connectorBoton lines 177โ€“178Oct 10, 2026

P2 Badge Reject or support all advertised history filters

LogHistoryOptions.filter is publicly typed as LogFilter, but filters containing only machineId or kind reach this branch with no store selector and silently return an empty page without making any history request. The same fields work for runtime.logs(), so callers cannot distinguish โ€œunsupported filter shapeโ€ from genuinely empty retained history; either support these filters or fail validation rather than reporting no logs.

Useful? React with ๐Ÿ‘ย / ๐Ÿ‘Ž.

Fixed in 752a3b620 by supporting them. The live stream already accepts a filter naming only a Server or a kind, so history now reads the same way. The only empty page left is a serviceId that matches no Service. A new SDK test reads one Server's hooks with { machineId, kind }; it fails before the change and passes now.

Added 1 commit

  • Carry the history cursor in a body and read history narrowed only by Server or kind752a3b6
Cchatgpt-codex-connectorBotreviewed ยท 1 inline threadOct 10, 2026
Synced from GitHub

๐Ÿ’ก Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 752a3b620c

โ„น๏ธ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with ๐Ÿ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

โ€ฆโ€ฆ@@ -45,21 +71,29 @@ function createLogStream(id: string, selection: ContainerLogSelection, scope: Co
84+ else {
85+ const { machineId, message } = decoded.value;
86+ const machineName = [...collection.values()].find(row => row.machineId === machineId)?.machineName ?? machineId;
87+ publish({ ...snapshot, missing: { ...snapshot.missing, live: { ...snapshot.missing.live, [machineId]: { machineName, message } } } });

P2 Badge Clear recovered servers from the live-missing map

When a container emits source_errorโ€”for example, because it disappears between the Runtime Watch snapshot and the TailLogs RPCโ€”this permanently adds its Machine to missing.live. Neither a later live event, successful records from a replacement container on that Machine, nor a successful history page removes the entry, so the dashboard continues claiming that the server's logs are missing until the collection is garbage-collected. Clear the Machine's live error when its log source or retained history recovers.

Useful? React with ๐Ÿ‘ย / ๐Ÿ‘Ž.

Fixed in a6ba30270, which is on #1484 because the per-container note lives in that layer. #1484 already clears a container from the note when it sends again. What was left is a container that has left for good. Each Runtime Watch frame is a complete observation. When a failed source's container is missing from a frame, and neither it nor its Machine is listed in incomplete_ids, the SDK now drops the source and yields source_gone. The dashboard clears that container from the note the same way a record does. The note stays while any other container on that Server is still down. Two new tests: an SDK test where the incomplete frame keeps the source and the complete frame yields source_gone, and a stream test where the last failed container leaves. The stream test failed without the fix and passes with it.