Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,11 @@ CODAG_CONSOLE=http://localhost:3000 codag auth login

After `codag setup`, supported agents can call Codag tools directly instead of
fetching raw logs through shell commands. For example, an agent can call
`tail_kubernetes`, `tail_aws_logs`, or the generic `wrap` tool and receive a
compact summary instead of thousands of raw log lines.
`tail_kubernetes` with `target=deployment/prod-api` and `namespace=prod` (or
`tail_aws_logs` / the generic `wrap` tool) and receive a compact summary
instead of thousands of raw log lines. Structured `tail_kubernetes` resolves
pods, fans out current and previous container logs when restarts are detected,
and includes Warning events in one compact call.

MCP log tools use the same compact endpoint as `codag wrap`: signed-in
workspaces follow the Free/Pro plan, and signed-out tools fall back to the
Expand Down
6 changes: 6 additions & 0 deletions cmd/help_llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ If codag MCP tools are available:

- Use named tools for supported providers: ` + "`tail_vercel`" + `, ` + "`tail_aws_logs`" + `,
` + "`tail_kubernetes`" + `, ` + "`tail_docker`" + `, ` + "`tail_gh_actions`" + `.
- For Kubernetes incidents prefer structured ` + "`tail_kubernetes`" + ` fields
(` + "`target`" + ` / ` + "`selector`" + ` / ` + "`namespace`" + `) so one call resolves pods,
fetches current + previous logs when containers restarted, includes Warning
events, and returns a single compact summary. Example:
` + "`target=\"deployment/prod-api\", namespace=\"prod\"`" + `.
Legacy ` + "`args=[...]`" + ` passthrough to ` + "`kubectl logs`" + ` still works.
- Use ` + "`wrap`" + ` for local files and provider CLIs without a named tool.
- Use ` + "`compact`" + ` only for log text that is already in chat or returned by another
tool and cannot be fetched from disk/provider CLI.
Expand Down
3 changes: 3 additions & 0 deletions cmd/help_llm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ func TestLLMDocDocumentsAgentContract(t *testing.T) {
"exit code is propagated",
// stats line shape
"est. tok",
// k8s structured incident path
"target=",
"deployment/prod-api",
} {
if !strings.Contains(llmDoc, want) {
t.Errorf("help-llm doc missing %q", want)
Expand Down
Binary file added docs/tail-kubernetes-demo/01-context.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/tail-kubernetes-demo/02-before-raw.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/tail-kubernetes-demo/03-structured-fetch.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/tail-kubernetes-demo/04-after-compact.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/tail-kubernetes-demo/05-comparison.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
163 changes: 163 additions & 0 deletions internal/k8s/fetch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package k8s

import (
"context"
"fmt"
"strings"
"time"
)

// FetchOpts controls a structured multi-pod log (+ optional events) fetch.
type FetchOpts struct {
Namespace string
Target string
Selector string
Since time.Duration
ForcePrevious bool // fetch --previous for every pod
IncludeEvents bool
MaxLines int // 0 = unlimited (still time-bounded by caller ctx)
Runner Runner
}

// FetchResult is the merged, tagged evidence ready for compaction.
type FetchResult struct {
Lines []string
Pods []PodRef
Notes []string
}

// FetchIncident resolves pods, fans out kubectl logs (and --previous when
// useful), optionally pulls Warning events, and returns tagged lines.
func FetchIncident(ctx context.Context, opts FetchOpts) (*FetchResult, error) {
if ctx == nil {
ctx = context.Background()
}
run := opts.Runner
if run == nil {
run = DefaultRunner
}
since := opts.Since
if since <= 0 {
since = 30 * time.Minute
}

pods, err := Resolve(ctx, run, ResolveOpts{
Namespace: opts.Namespace,
Target: opts.Target,
Selector: opts.Selector,
})
if err != nil {
return nil, err
}
if len(pods) == 0 {
return nil, fmt.Errorf("no pods matched target=%q selector=%q namespace=%q",
opts.Target, opts.Selector, opts.Namespace)
}
capped := CapPods(pods)
var notes []string
if len(capped) < len(pods) {
notes = append(notes, fmt.Sprintf("matched %d pods; fetching the first %d", len(pods), len(capped)))
}

var lines []string
emitted := 0

for _, p := range capped {
if ctx.Err() != nil {
notes = append(notes, "interrupted while collecting pod logs")
break
}
if opts.MaxLines > 0 && emitted >= opts.MaxLines {
notes = append(notes, fmt.Sprintf("stopped after %d lines (budget exhausted)", emitted))
break
}

needPrevious := opts.ForcePrevious
if !needPrevious {
if n, err := PodRestarts(ctx, run, p); err == nil && n > 0 {
needPrevious = true
}
}

n, err := collectPodLogs(ctx, run, p, since, false, &lines, &emitted, opts.MaxLines)
if err != nil {
notes = append(notes, fmt.Sprintf("%s current logs: %v", p.Ref, err))
} else if n == 0 {
notes = append(notes, fmt.Sprintf("%s produced no current log lines", p.Ref))
}

if needPrevious {
if opts.MaxLines > 0 && emitted >= opts.MaxLines {
break
}
_, prevErr := collectPodLogs(ctx, run, p, since, true, &lines, &emitted, opts.MaxLines)
if prevErr != nil {
// --previous often fails when no prior container exists; note softly.
notes = append(notes, fmt.Sprintf("%s previous logs unavailable: %v", p.Ref, prevErr))
}
}
}

if opts.IncludeEvents {
evArgs := EventsArgs(opts.Namespace, since)
out, err := run(ctx, "kubectl", evArgs...)
if err != nil {
notes = append(notes, fmt.Sprintf("events: %v", err))
} else {
raw := strings.Split(string(out), "\n")
kept := FilterRecentEvents(raw, since, time.Now())
for _, line := range kept {
if opts.MaxLines > 0 && emitted >= opts.MaxLines {
break
}
lines = append(lines, TagEventLine(line))
emitted++
}
}
}

if len(lines) == 0 {
msg := "no log lines collected from matched pods"
if len(notes) > 0 {
msg += ": " + strings.Join(notes, "; ")
}
return nil, fmt.Errorf("%s", msg)
}

return &FetchResult{Lines: lines, Pods: capped, Notes: notes}, nil
}

func collectPodLogs(
ctx context.Context,
run Runner,
p PodRef,
since time.Duration,
previous bool,
lines *[]string,
emitted *int,
budget int,
) (int, error) {
args := LogArgs(p, since, previous)
out, err := run(ctx, "kubectl", args...)
if err != nil {
return 0, err
}
n := 0
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimRight(line, "\r")
if strings.TrimSpace(line) == "" {
continue
}
msg := line
if previous {
msg = "[previous] " + line
}
*lines = append(*lines, TagLogLine(p, msg))
*emitted++
n++
if budget > 0 && *emitted >= budget {
break
}
}
return n, nil
}
Loading