Skip to content

feat(logs): System logs, log rules, and AI triage - #206

Merged
nfebe merged 10 commits into
mainfrom
feat/system-logs-and-log-triage
Aug 10, 2026
Merged

feat(logs): System logs, log rules, and AI triage#206
nfebe merged 10 commits into
mainfrom
feat/system-logs-and-log-triage

Conversation

@nfebe

@nfebe nfebe commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

The panel could not show the proxy's own logs, so a request that never reached a deployment was
invisible everywhere, and the access log could not say which site a line belonged to. Access lines
now lead with the host, which is what makes one shared log readable per deployment.

Log triage is bounded on purpose. Lines are collapsed by shape before anything is counted, so a
thousand repeats of one fault are one incident; only an incident reaches the assistant, the
context handed over is capped, and a daily ceiling bounds the spend. Acting on an incident goes
through a registry so notifying is the first responder rather than the only possible one.

Deleting a log truncates in place rather than unlinking, so the application keeps writing to the
same file. A source the runtime does not keep on disk is refused with the reason instead of
appearing to succeed.

The service filter was a regression: it had been dropped from the compose call.

Needs the matching ui branch. internal/docker tests time out on TestPullDeployment, which
pulls images over the network and does the same on main.

nfebe added 5 commits August 10, 2026 20:32
Formatting only. No behaviour changes.
Reading or following a deployment's logs can again be narrowed to a single container instead of
always returning every service interleaved. The requested name is checked against the
deployment's own compose services first, so an unknown one is rejected with the list of what is
available rather than reaching the container runtime.

A file log source stays whole, since it is one file the deployment writes rather than per
service output.
The reverse proxy's access and error logs are now readable and followable from the panel, so a
request that never reached a deployment is visible instead of being invisible everywhere. Access
lines now start with the host they were served for, which makes the shared log filterable down to
a single deployment by matching its domains.

Access and error are offered separately because they are separate outputs, and reading them needs
infrastructure access rather than access to any one deployment.
A log can now be emptied from the panel, which frees the disk it was using. A file source is
emptied in place so the application keeps writing to the same file, and container output is
emptied where the container runtime stores it. A source the runtime does not keep on disk is
refused with the reason rather than appearing to succeed.

Emptying a deployment's log needs write access to that deployment, and emptying a system log
needs infrastructure write access, since neither is recoverable.
Logs can now be watched for patterns that matter, so a repeated error raises an incident on its
own instead of waiting for someone to be looking at the right screen at the right moment. A rule
sets the level and pattern that count, how many times within how long, and how long to stay quiet
after firing.

Only an incident reaches the assistant, and only once for each distinct fault: identical lines are
collapsed by shape before anything is counted, the context handed over is capped, and a daily
ceiling bounds the spend. Incidents carry a short explanation and suggested next step where the
assistant is configured, and the plain incident where it is not.

Acting on an incident goes through a registry, so notifying is the first responder rather than the
only possible one.
@sourceant

sourceant Bot commented Aug 10, 2026

Copy link
Copy Markdown

Code Review Summary

This PR introduces a robust system for log management, including the ability to stream and filter proxy access logs per deployment, an automated log rule engine for incident detection, and an AI-powered triage system with built-in cost controls.

🚀 Key Improvements

  • Added $host to Nginx log formats to enable reliable multi-tenant log filtering.
  • Implemented a log fingerprinting engine that collapses thousands of identical log lines into single incidents.
  • Added a triageBudget system to prevent unexpected AI provider costs from unattended log analysis.
  • Introduced log truncation (truncating in place) to allow clearing logs without breaking container file descriptors.

💡 Minor Suggestions

  • Optimize the host matcher in internal/api/system_logs.go to avoid splitting the entire log line into strings.
  • Use bufio.Scanner instead of strings.Split when processing snapshots of system logs.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread internal/api/internal_triage.go
Finding where Docker stores a container's output no longer spawns a CLI process for it, and the
call sits with the rest of the Docker work rather than in the HTTP layer.

A triage prompt that has to be shortened is now cut on a character boundary, so a log line in any
language cannot end in half a character and leave the prompt invalid.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread internal/api/internal_triage.go
Comment thread internal/api/log_truncate.go
Asking to empty the logs of a service that does not exist answered with success and a count of
zero. It now fails with the names that do exist, the same way reading them does.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread internal/api/internal_logs.go
Comment thread internal/observ/logwatch.go
The access-log test asked for a page the moment the container was created, before nginx had
finished starting, so it failed or passed depending on the timing of the machine it ran on.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread internal/observ/logengine.go Outdated
Comment thread internal/observ/logwatch.go
…ained

Raising an incident used to stop the reader that raised it: the model call ran on the same
goroutine, so a deployment writing logs went unread for as long as the answer took, up to
forty-five seconds. A busy app could fall far behind exactly when something was going wrong with
it.

Detection now ends at the incident. Explaining it and acting on it happen away from the reader,
one at a time, so a burst of incidents cannot become a burst of model calls either. An incident
raised while the queue is backed up is still recorded and still shown; it simply arrives without
an explanation.

A line is now placed in time by when it was written rather than when it was read, so a reconnect
no longer squeezes a spread-out burst into an instant and trips a rule that nothing tripped. A
timestamp far from the present is treated as a wrong clock and ignored.

Asking the internal stream for a source that cannot be read now fails with the reason instead of
answering success and then falling silent.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread internal/observ/logengine.go
Comment thread internal/observ/logwatch.go
…onger needs

The engine kept a little state per stream it had ever seen and per distinct shape of message it
had ever matched, and never let either go. On an agent that runs for months, every container that
ever wrote a line and every variation of an error stayed in memory for the life of the process.

Both are now dropped once they have stopped deciding anything: a burst window after its own window
and cooldown have passed, and a stream's recent lines after it has been quiet for an hour. A
stream that starts writing again simply builds its context back up.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

return func(line string) bool {
// Lines arrive with docker's timestamp ahead of nginx's own fields, so the host is
// one of the first two fields depending on whether that prefix is present.
fields := strings.Fields(line)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using strings.Fields(line) on every access log line is inefficient because it allocates a slice for the entire line, including potentially long query strings, user agents, or referers. Since the proxy log format leads with the host (and optionally a Docker timestamp), you only need to check the first two whitespace-separated fields. A manual search for the first few spaces would avoid significant allocation overhead in high-traffic environments.

Suggested change
fields := strings.Fields(line)
// Avoid splitting the entire line; we only care about the host which is
// in the first or second field (depending on the presence of a timestamp).
start := 0
for f := 0; f < 2; f++ {
for start < len(line) && (line[start] == ' ' || line[start] == '\t') {
start++
}
if start >= len(line) {
break
}
end := start
for end < len(line) && line[end] != ' ' && line[end] != '\t' {
end++
}
host := strings.ToLower(line[start:end])
host = strings.TrimSuffix(host, ":443")
host = strings.TrimSuffix(host, ":80")
if _, ok := hosts[host]; ok {
return true
}
start = end
}

return
}

kept := make([]string, 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using strings.Split on the entire log block can be memory-intensive if the log is large, as it creates a large slice of string pointers. Using a bufio.Scanner with a strings.NewReader would be more memory-efficient here.

Suggested change
kept := make([]string, 0)
kept := make([]string, 0)
scanner := bufio.NewScanner(strings.NewReader(logs))
for scanner.Scan() {
line := scanner.Text()
if line != "" && keep(line) {
kept = append(kept, line)
}
}

@nfebe
nfebe merged commit 751d3ed into main Aug 10, 2026
6 checks passed
@nfebe
nfebe deleted the feat/system-logs-and-log-triage branch August 10, 2026 23:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant