-
Notifications
You must be signed in to change notification settings - Fork 3
feat(logs): System logs, log rules, and AI triage #206
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
58e4ae6
chore: Apply gofmt to misaligned struct fields
nfebe db27084
fix(logs): Restore filtering a deployment's logs by service
nfebe cf188b9
feat(logs): Expose the proxy's own logs, readable per deployment
nfebe 1e62e18
feat(logs): Delete a log from the panel
nfebe da86e18
feat(observability): Watch logs against rules and triage what fires
nfebe 9c583fa
fix(logs): Read a container's log path through the Docker API
nfebe 3ec9cbc
fix(logs): Reject a service name that no container matches on delete
nfebe 825e443
test(logs): Wait for the proxy to accept the request before making it
nfebe 22a3ff8
fix(observability): Keep reading logs while an incident is being expl…
nfebe 4dfeaa2
fix(observability): Forget streams and message shapes the funnel no l…
nfebe File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| package api | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "net/http" | ||
| "strconv" | ||
|
|
||
| "github.com/flatrun/agent/pkg/models" | ||
| "github.com/gin-gonic/gin" | ||
| ) | ||
|
|
||
| // streamInternalLogs hands a deployment's log lines to a built-in app as newline-delimited | ||
| // JSON. The user-facing stream is a websocket because a browser cannot set headers; an app | ||
| // can, so it gets the simpler transport and the same reader, which keeps log sources, the | ||
| // service filter and level parsing in one implementation. | ||
| func (s *Server) streamInternalLogs(c *gin.Context) { | ||
| if s.pluginToken == "" || c.GetHeader("X-Plugin-Token") != s.pluginToken { | ||
| c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) | ||
| return | ||
| } | ||
|
|
||
| name := c.Query("deployment") | ||
| if name == "" { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": "deployment required"}) | ||
| return | ||
| } | ||
|
|
||
| deployment, err := s.manager.GetDeployment(name) | ||
| if err != nil { | ||
| c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) | ||
| return | ||
| } | ||
|
|
||
| source, ok := resolveLogSource(deployment.Metadata, c.Query("source")) | ||
| if !ok { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": "unknown log source"}) | ||
| return | ||
| } | ||
|
|
||
| services, err := s.resolveLogServices(name, c.Query("service")) | ||
| if err != nil { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | ||
| return | ||
| } | ||
|
|
||
| // Replaying a day of history on every reconnect would re-raise handled incidents. | ||
| tail := 0 | ||
| if v := c.Query("tail"); v != "" { | ||
| if n, parseErr := strconv.Atoi(v); parseErr == nil && n >= 0 { | ||
| tail = n | ||
| } | ||
| } | ||
|
|
||
| // Everything that can fail is resolved before the status goes out, since a 200 followed by | ||
| // silence is indistinguishable from a stream that has nothing to say yet. | ||
| var filePath string | ||
| if source.Type == models.LogSourceFile { | ||
| filePath, err = resolveLogFilePath(deployment.Path, source.Path) | ||
| if err != nil { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | ||
| return | ||
| } | ||
| } | ||
|
|
||
| c.Writer.Header().Set("Content-Type", "application/x-ndjson") | ||
| c.Writer.Header().Set("Cache-Control", "no-cache") | ||
| c.Writer.WriteHeader(http.StatusOK) | ||
| c.Writer.Flush() | ||
|
|
||
| ctx := c.Request.Context() | ||
| encoder := json.NewEncoder(c.Writer) | ||
|
|
||
| sink := func(line string) { | ||
| record := parseLogRecord(line) | ||
| if source.Type == models.LogSourceFile && record.Service == "" { | ||
| record.Service = source.Name | ||
| } | ||
| if err := encoder.Encode(logLine{Type: "log", Line: line, Record: record}); err != nil { | ||
| return | ||
| } | ||
| c.Writer.Flush() | ||
| } | ||
|
|
||
| if source.Type == models.LogSourceFile { | ||
| _ = streamFileLogs(ctx, filePath, tail, sink) | ||
| return | ||
| } | ||
| _ = s.manager.StreamDeploymentLogs(ctx, name, deployment.Path, tail, sink, services...) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| package api | ||
|
|
||
| import ( | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/flatrun/agent/internal/docker" | ||
| "github.com/gin-gonic/gin" | ||
| ) | ||
|
|
||
| // A stream that answers 200 and then says nothing looks identical to one that has nothing to | ||
| // report yet, so a source that cannot be read has to fail before the status goes out. | ||
| func TestInternalLogStreamFailsBeforeAnsweringOK(t *testing.T) { | ||
| gin.SetMode(gin.TestMode) | ||
| base, name := writeLogFilterDeployment(t) | ||
|
|
||
| metadata := "log_sources:\n - id: escape\n name: Escape\n type: file\n path: ../outside.log\n" | ||
| if err := os.WriteFile(filepath.Join(base, name, "service.yml"), []byte(metadata), 0644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| server := &Server{manager: docker.NewManager(base), pluginToken: "plugin-secret"} | ||
| router := gin.New() | ||
| router.GET("/internal/logs/stream", server.streamInternalLogs) | ||
|
|
||
| req := httptest.NewRequest(http.MethodGet, "/internal/logs/stream?deployment="+name+"&source=escape", nil) | ||
| req.Header.Set("X-Plugin-Token", "plugin-secret") | ||
| w := httptest.NewRecorder() | ||
| router.ServeHTTP(w, req) | ||
|
|
||
| if w.Code != http.StatusBadRequest { | ||
| t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| package api | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "testing" | ||
| ) | ||
|
|
||
| // The observability app reads this envelope to decide what is an incident. It is a wire | ||
| // contract between two packages that are compiled together but talk over HTTP, so the shape | ||
| // is pinned here and the app's watcher test decodes the same literal from the other side. | ||
| func TestInternalLogEnvelopeShape(t *testing.T) { | ||
| raw := "web-1 | 2026-08-06T12:00:31.123456Z ERROR connection refused talking to redis" | ||
|
|
||
| encoded, err := json.Marshal(logLine{Type: "log", Line: raw, Record: parseLogRecord(raw)}) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| var decoded struct { | ||
| Type string `json:"type"` | ||
| Line string `json:"line"` | ||
| Record struct { | ||
| Timestamp string `json:"timestamp"` | ||
| Service string `json:"service"` | ||
| Level string `json:"level"` | ||
| Message string `json:"message"` | ||
| } `json:"record"` | ||
| } | ||
| if err := json.Unmarshal(encoded, &decoded); err != nil { | ||
| t.Fatalf("the envelope must decode into the shape the watcher expects: %v", err) | ||
| } | ||
|
|
||
| if decoded.Type != "log" { | ||
| t.Errorf("type = %q, want log", decoded.Type) | ||
| } | ||
| if decoded.Line != raw { | ||
| t.Errorf("line should be the untouched original, got %q", decoded.Line) | ||
| } | ||
| if decoded.Record.Service != "web-1" { | ||
| t.Errorf("service should come from the compose prefix, got %q", decoded.Record.Service) | ||
| } | ||
| if decoded.Record.Level != "error" { | ||
| t.Errorf("level should be parsed to a canonical name, got %q", decoded.Record.Level) | ||
| } | ||
| // The compose prefix and the leading timestamp are stripped; the level word stays in the | ||
| // message, which is what the app fingerprints on. | ||
| if decoded.Record.Message != "ERROR connection refused talking to redis" { | ||
| t.Errorf("message should be the line without the compose prefix or timestamp, got %q", decoded.Record.Message) | ||
| } | ||
| if decoded.Record.Timestamp != "2026-08-06T12:00:31.123456Z" { | ||
| t.Errorf("timestamp should be lifted out of the line, got %q", decoded.Record.Timestamp) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.