Skip to content
Draft
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
73 changes: 38 additions & 35 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ LAPP (Log Auto Pattern Pipeline) discovers log templates from log streams using
## Commands

```bash
make build # Build embedded frontend assets, then output/lapp
make clean # Remove generated build artifacts
make dev # Clean, build, and start lapp web on 127.0.0.1:8080
make proto-gen # Generate protobuf/Connect code
make test # Run unit and integration tests
make check # Run formatting, linting, type checks, build, and unit tests
make build # Build embedded frontend assets, then output/lapp
make clean # Remove generated build artifacts
make dev # Clean, build, and start lapp web on 127.0.0.1:8080
make proto-gen # Generate protobuf/Connect code
make test # Run unit and integration tests
make check # Run formatting, linting, type checks, build, and unit tests

# Run a single test
go test -v -run TestFunctionName ./pkg/pattern/
Expand All @@ -24,10 +24,11 @@ go test -v -run TestFunctionName ./pkg/pattern/

```bash
go run ./cmd/lapp/ workspace create <topic>
go run ./cmd/lapp/ workspace add-log --topic <topic> <logfile> [--model <model>]
go run ./cmd/lapp/ workspace add-log --topic <topic> --stdin [--model <model>]
go run ./cmd/lapp/ workspace analyze --topic <topic> [question] [--model <model>]
go run ./cmd/lapp/ web [--addr 127.0.0.1:0]
go run ./cmd/lapp/ workspace add-log --topic <topic> <logfile> [--model <model>]
go run ./cmd/lapp/ workspace add-log --topic <topic> --stdin [--model <model>]
go run ./cmd/lapp/ workspace add-log --topic <topic> --gcp-project <project> [--gcp-filter <filter>] [--since 1h] [--limit 10000]
go run ./cmd/lapp/ workspace analyze --topic <topic> [question] [--model <model>]
go run ./cmd/lapp/ web [--addr 127.0.0.1:0]
```

Topic names are sanitized to lower-kebab-case. Workspaces live under `~/.lapp/workspaces/<topic>/`.
Expand All @@ -37,33 +38,34 @@ Topic names are sanitized to lower-kebab-case. Workspaces live under `~/.lapp/wo
```
cmd/lapp/ CLI entrypoint (cobra commands: workspace create/add-log/analyze)
pkg/logsource/ Read log files → channel of LogLine
pkg/gcplog/ Fetch Google Cloud Logging entries → text log lines
pkg/multiline/ Detect log entry boundaries, merge continuation lines
pkg/pattern/ Drain-based log pattern discovery and template matching
pkg/semantic/ LLM-based semantic labeling of Drain patterns
pkg/workspace/ DiscoveryRun execution and run-scoped file writer
pkg/store/ DuckDB storage primitives (not yet on the CLI add-log path)
pkg/workspace/ DiscoveryRun execution and run-scoped file writer
pkg/store/ DuckDB storage primitives (not yet on the CLI add-log path)
pkg/config/ Model resolution (flag → $MODEL_NAME → default)
pkg/analyzer/ Agentic log analysis via eino ADK + ACP providers
pkg/analyzer/ Agentic log analysis via eino ADK + ACP providers
integration_test/ Integration tests against Loghub-2.0 datasets
```

### DiscoveryRun (add-log)

Each `add-log` copies a log file, then starts a DiscoveryRun: reads ALL files in `logs/`, runs fresh Drain + semantic labeling, and writes run-scoped `patterns/` and `notes/`.
When `lapp web` starts, it marks any previous `QUEUED` or `RUNNING` DiscoveryRuns as failed because those local workers no longer exist.
DiscoveryRun records persist structured `progress` and `error` fields; frontend code renders those facts into user-facing text.

```
workspace.Discover(ctx, cfg)
→ Read all logs/ files → multiline.MergeSlice() per file → tagged lines
→ pattern.DrainParser.Feed(all content) → Templates() → filter Count > 1
→ semantic.Label(ctx, cfg, patterns) ← LLM batches with per-batch retry
→ workspace.NewBuilder(...).BuildAll()
→ discovery-runs/<run-id>/patterns/<semantic-id>/pattern.md + samples.log
→ discovery-runs/<run-id>/patterns/unmatched/samples.log
→ discovery-runs/<run-id>/notes/summary.md + errors.md
→ discovery-runs/<run-id>/AGENTS.md
```
### DiscoveryRun (add-log)
Each `add-log` copies a log file, then starts a DiscoveryRun: reads ALL files in `logs/`, runs fresh Drain + semantic labeling, and writes run-scoped `patterns/` and `notes/`.
When `lapp web` starts, it marks any previous `QUEUED` or `RUNNING` DiscoveryRuns as failed because those local workers no longer exist.
DiscoveryRun records persist structured `progress` and `error` fields; frontend code renders those facts into user-facing text.
```
workspace.Discover(ctx, cfg)
→ Read all logs/ files → multiline.MergeSlice() per file → tagged lines
→ pattern.DrainParser.Feed(all content) → Templates() → filter Count > 1
→ semantic.Label(ctx, cfg, patterns) ← LLM batches with per-batch retry
→ workspace.NewBuilder(...).BuildAll()
→ discovery-runs/<run-id>/patterns/<semantic-id>/pattern.md + samples.log
→ discovery-runs/<run-id>/patterns/unmatched/samples.log
→ discovery-runs/<run-id>/notes/summary.md + errors.md
→ discovery-runs/<run-id>/AGENTS.md
```

### Multiline Detection

Expand All @@ -75,14 +77,15 @@ Runs an eino ADK agent (15 max iterations) with filesystem tools (grep, read_fil

## Environment Variables

- `OPENROUTER_API_KEY`: Required for semantic labeling in `workspace add-log`
- `MODEL_NAME`: Override default LLM model (default: `google/gemini-3-flash-preview`)
- ACP provider credentials/login: Required for `workspace analyze` through the selected provider
- `.env` file is auto-loaded via godotenv
- `OPENROUTER_API_KEY`: Required for semantic labeling in `workspace add-log`
- `MODEL_NAME`: Override default LLM model (default: `google/gemini-3-flash-preview`)
- ACP provider credentials/login: Required for `workspace analyze` through the selected provider
- Google Application Default Credentials: Required for GCP log import (`add-log --gcp-project` and the web ImportLogs RPC); run `gcloud auth application-default login`
- `.env` file is auto-loaded via godotenv

## Tech Stack

- Go, cobra CLI, go-drain3, DuckDB (duckdb-go/v2), cloudwego/eino ADK, OpenRouter semantic labeling, ACP providers
- Go, cobra CLI, go-drain3, DuckDB (duckdb-go/v2), cloudwego/eino ADK, OpenRouter semantic labeling, ACP providers

## Code Style

Expand Down
66 changes: 64 additions & 2 deletions cmd/lapp/workspace.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"fmt"
"io"
"log/slog"
Expand All @@ -13,6 +14,7 @@ import (
"github.com/go-errors/errors"
"github.com/spf13/cobra"
"github.com/strrl/lapp/pkg/analyzer"
"github.com/strrl/lapp/pkg/gcplog"
"github.com/strrl/lapp/pkg/workspace"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
Expand Down Expand Up @@ -149,6 +151,10 @@ Use ` + "`lapp workspace add-log --topic " + topic + " <logfile>`" + ` to add lo
var addLogModel string
var addLogStdin bool
var addLogTopic string
var addLogGCPProject string
var addLogGCPFilter string
var addLogGCPSince time.Duration
var addLogGCPLimit int

func workspaceAddLogCmd() *cobra.Command {
cmd := &cobra.Command{
Expand All @@ -157,13 +163,20 @@ func workspaceAddLogCmd() *cobra.Command {
Long: `Copy a log file into the workspace's logs/ directory, then run the full
DiscoveryRun flow (Drain clustering + semantic labeling) to generate run-scoped results.

Logs can come from a file, stdin, or Google Cloud Logging (--gcp-project,
requires Application Default Credentials).

Requires OPENROUTER_API_KEY environment variable.`,
Args: cobra.MaximumNArgs(1),
RunE: runWorkspaceAddLog,
}
cmd.Flags().StringVar(&addLogTopic, "topic", "", "workspace topic (required)")
cmd.Flags().StringVar(&addLogModel, "model", "", "override LLM model")
cmd.Flags().BoolVar(&addLogStdin, "stdin", false, "read log from stdin")
cmd.Flags().StringVar(&addLogGCPProject, "gcp-project", "", "import logs from this Google Cloud project")
cmd.Flags().StringVar(&addLogGCPFilter, "gcp-filter", "", "Cloud Logging filter expression")
cmd.Flags().DurationVar(&addLogGCPSince, "since", time.Hour, "how far back to fetch Cloud Logging entries")
cmd.Flags().IntVar(&addLogGCPLimit, "limit", 10000, "maximum number of Cloud Logging entries to fetch")
_ = cmd.MarkFlagRequired("topic")
return cmd
}
Expand All @@ -188,7 +201,7 @@ func runWorkspaceAddLog(cmd *cobra.Command, args []string) error {
ctx, span := otel.Tracer("lapp/cmd").Start(cmd.Context(), "cmd.WorkspaceAddLog")
defer span.End()

if err := copyLogToWorkspace(dir, args, span.SetAttributes); err != nil {
if err := copyLogToWorkspace(ctx, dir, args, span.SetAttributes); err != nil {
return err
}

Expand All @@ -206,7 +219,17 @@ func runWorkspaceAddLog(cmd *cobra.Command, args []string) error {
return nil
}

func copyLogToWorkspace(dir string, args []string, setSpanAttributes func(...attribute.KeyValue)) error {
func copyLogToWorkspace(ctx context.Context, dir string, args []string, setSpanAttributes func(...attribute.KeyValue)) error {
if addLogGCPProject != "" {
if addLogStdin {
return errors.New("--stdin and --gcp-project are mutually exclusive")
}
if len(args) > 0 {
return errors.New("logfile argument and --gcp-project are mutually exclusive")
}
return importGCPLogs(ctx, dir, setSpanAttributes)
}

if addLogStdin {
name := fmt.Sprintf("stdin-%d.log", time.Now().UnixNano())
data, err := io.ReadAll(os.Stdin)
Expand Down Expand Up @@ -238,6 +261,45 @@ func copyLogToWorkspace(dir string, args []string, setSpanAttributes func(...att
return nil
}

// importGCPLogs fetches Cloud Logging entries into the workspace logs
// directory as a regular log file.
func importGCPLogs(ctx context.Context, dir string, setSpanAttributes func(...attribute.KeyValue)) error {
setSpanAttributes(attribute.String("gcp.project", addLogGCPProject))

name := fmt.Sprintf("gcp-%s-%s.log", addLogGCPProject, time.Now().UTC().Format("20060102-150405"))
path := filepath.Join(dir, "logs", name)
file, err := os.Create(path)
if err != nil {
return errors.Errorf("create import log file: %w", err)
}

count, fetchErr := gcplog.NewFetcher().Fetch(ctx, gcplog.FetchConfig{
ProjectID: addLogGCPProject,
Filter: addLogGCPFilter,
Since: time.Now().Add(-addLogGCPSince),
Limit: addLogGCPLimit,
OnProgress: func(fetched int) {
slog.Info("Fetching GCP logs", "entries", fetched)
},
}, file)
closeErr := file.Close()
if fetchErr != nil || closeErr != nil || count == 0 {
_ = os.Remove(path)
}
if fetchErr != nil {
return errors.Errorf("fetch gcp logs: %w", fetchErr)
}
if closeErr != nil {
return errors.Errorf("close import log file: %w", closeErr)
}
if count == 0 {
return errors.New("no log entries matched the filter and time range")
}

slog.Info("Imported GCP logs", "name", name, "entries", count)
return nil
}

var analyzeWsModel string
var analyzeWsACP string
var analyzeTopic string
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/gen/lapp/event/v1/event_pb.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// @generated by protoc-gen-es v2.12.0 with parameter "target=ts"
// @generated by protoc-gen-es v2.12.1 with parameter "target=ts"
// @generated from file lapp/event/v1/event.proto (package lapp.event.v1, syntax proto3)
/* eslint-disable */

Expand Down
Loading
Loading