Skip to content

Commit fb5c9be

Browse files
authored
Merge pull request #27 from bdclaw2026/bdclaw/bor-518-phase-2ingestion-foundation-build-raw-log-line-ingestion
BOR-518: add raw line ingestion fallback
2 parents 3e96b66 + cc0a454 commit fb5c9be

2 files changed

Lines changed: 277 additions & 0 deletions

File tree

pkg/ingest/raw.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package ingest
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/go-errors/errors"
8+
"github.com/strrl/lapp/pkg/event"
9+
"github.com/strrl/lapp/pkg/logsource"
10+
"github.com/strrl/lapp/pkg/store"
11+
)
12+
13+
// ParseResult carries parser-derived event data plus optional storage labels.
14+
type ParseResult struct {
15+
Timestamp *time.Time
16+
Attrs map[string]string
17+
Inferred *event.Inferred
18+
Labels map[string]string
19+
}
20+
21+
// Parser extracts structured data from a raw log line.
22+
type Parser interface {
23+
Parse(ctx context.Context, raw string) (*ParseResult, error)
24+
}
25+
26+
// Outcome captures the normalized event, the storage record, and any parser error.
27+
type Outcome struct {
28+
Event event.Event
29+
LogEntry store.LogEntry
30+
ParseErr error
31+
}
32+
33+
// FromRawLine normalizes a raw log line and keeps the original text as the source of truth.
34+
// Parser failures are surfaced on the Outcome but still return a fallback event/log entry.
35+
func FromRawLine(ctx context.Context, line *logsource.LogLine, parser Parser) (Outcome, error) {
36+
if line == nil {
37+
return Outcome{}, errors.New("raw log line is required")
38+
}
39+
40+
outcome := Outcome{
41+
Event: event.Event{
42+
Text: line.Content,
43+
Attrs: map[string]string{},
44+
Inferred: &event.Inferred{},
45+
},
46+
LogEntry: store.LogEntry{
47+
LineNumber: line.LineNumber,
48+
EndLineNumber: line.LineNumber,
49+
Raw: line.Content,
50+
Labels: map[string]string{},
51+
},
52+
}
53+
54+
if parser == nil {
55+
return outcome, nil
56+
}
57+
58+
parsed, parseErr := parser.Parse(ctx, line.Content)
59+
if parseErr != nil {
60+
outcome.ParseErr = parseErr
61+
} else if parsed != nil {
62+
if parsed.Timestamp != nil {
63+
ts := *parsed.Timestamp
64+
outcome.Event.Timestamp = &ts
65+
outcome.LogEntry.Timestamp = ts
66+
}
67+
if parsed.Attrs != nil {
68+
outcome.Event.Attrs = cloneMap(parsed.Attrs)
69+
}
70+
if parsed.Inferred != nil {
71+
outcome.Event.Inferred = &event.Inferred{
72+
Pattern: parsed.Inferred.Pattern,
73+
Entity: parsed.Inferred.Entity,
74+
}
75+
}
76+
if parsed.Labels != nil {
77+
outcome.LogEntry.Labels = cloneMap(parsed.Labels)
78+
}
79+
}
80+
81+
return outcome, nil
82+
}
83+
84+
// StoreRawLine persists the raw line outcome. Parser failures never prevent storage.
85+
func StoreRawLine(ctx context.Context, dst store.Store, line *logsource.LogLine, parser Parser) (Outcome, error) {
86+
if dst == nil {
87+
return Outcome{}, errors.New("store is required")
88+
}
89+
90+
outcome, err := FromRawLine(ctx, line, parser)
91+
if err != nil {
92+
return Outcome{}, err
93+
}
94+
if err := dst.InsertLog(ctx, outcome.LogEntry); err != nil {
95+
return Outcome{}, errors.Errorf("insert raw log line: %w", err)
96+
}
97+
return outcome, nil
98+
}
99+
100+
func cloneMap(src map[string]string) map[string]string {
101+
if src == nil {
102+
return nil
103+
}
104+
cloned := make(map[string]string, len(src))
105+
for key, value := range src {
106+
cloned[key] = value
107+
}
108+
return cloned
109+
}

pkg/ingest/raw_test.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package ingest
2+
3+
import (
4+
"context"
5+
stderrors "errors"
6+
"testing"
7+
"time"
8+
9+
"github.com/strrl/lapp/pkg/event"
10+
"github.com/strrl/lapp/pkg/logsource"
11+
"github.com/strrl/lapp/pkg/store"
12+
)
13+
14+
type stubParser struct {
15+
parse func(context.Context, string) (*ParseResult, error)
16+
}
17+
18+
func (s stubParser) Parse(ctx context.Context, raw string) (*ParseResult, error) {
19+
return s.parse(ctx, raw)
20+
}
21+
22+
func newTestStore(t *testing.T) *store.DuckDBStore {
23+
t.Helper()
24+
25+
s, err := store.NewDuckDBStore("")
26+
if err != nil {
27+
t.Fatalf("NewDuckDBStore: %v", err)
28+
}
29+
t.Cleanup(func() { _ = s.Close() })
30+
31+
if err := s.Init(context.Background()); err != nil {
32+
t.Fatalf("Init: %v", err)
33+
}
34+
35+
return s
36+
}
37+
38+
func TestFromRawLineWithoutParserUsesFallbackEvent(t *testing.T) {
39+
line := &logsource.LogLine{
40+
LineNumber: 7,
41+
Content: " WARN worker stalled for 30s ",
42+
}
43+
44+
outcome, err := FromRawLine(context.Background(), line, nil)
45+
if err != nil {
46+
t.Fatalf("FromRawLine: %v", err)
47+
}
48+
49+
if outcome.ParseErr != nil {
50+
t.Fatalf("ParseErr: got %v, want nil", outcome.ParseErr)
51+
}
52+
if outcome.Event.Text != line.Content {
53+
t.Fatalf("Event.Text: got %q, want %q", outcome.Event.Text, line.Content)
54+
}
55+
if len(outcome.Event.Attrs) != 0 {
56+
t.Fatalf("Event.Attrs: got %v, want empty map", outcome.Event.Attrs)
57+
}
58+
if outcome.Event.Inferred == nil {
59+
t.Fatal("Event.Inferred: got nil, want non-nil empty inferred")
60+
}
61+
if outcome.LogEntry.Raw != line.Content {
62+
t.Fatalf("LogEntry.Raw: got %q, want %q", outcome.LogEntry.Raw, line.Content)
63+
}
64+
if outcome.LogEntry.LineNumber != line.LineNumber || outcome.LogEntry.EndLineNumber != line.LineNumber {
65+
t.Fatalf("log entry line bounds: got (%d,%d), want (%d,%d)", outcome.LogEntry.LineNumber, outcome.LogEntry.EndLineNumber, line.LineNumber, line.LineNumber)
66+
}
67+
}
68+
69+
func TestStoreRawLinePersistsWhenParserFails(t *testing.T) {
70+
s := newTestStore(t)
71+
line := &logsource.LogLine{
72+
LineNumber: 12,
73+
Content: "ERROR checkout request req_123 timed out",
74+
}
75+
76+
outcome, err := StoreRawLine(context.Background(), s, line, stubParser{
77+
parse: func(context.Context, string) (*ParseResult, error) {
78+
return nil, context.DeadlineExceeded
79+
},
80+
})
81+
if err != nil {
82+
t.Fatalf("StoreRawLine: %v", err)
83+
}
84+
if !stderrors.Is(outcome.ParseErr, context.DeadlineExceeded) {
85+
t.Fatalf("ParseErr: got %v, want %v", outcome.ParseErr, context.DeadlineExceeded)
86+
}
87+
if outcome.Event.Text != line.Content {
88+
t.Fatalf("Event.Text: got %q, want %q", outcome.Event.Text, line.Content)
89+
}
90+
91+
stored, err := s.QueryLogs(context.Background(), store.QueryOpts{})
92+
if err != nil {
93+
t.Fatalf("QueryLogs: %v", err)
94+
}
95+
if len(stored) != 1 {
96+
t.Fatalf("stored entries: got %d, want 1", len(stored))
97+
}
98+
if stored[0].Raw != line.Content {
99+
t.Fatalf("stored raw: got %q, want %q", stored[0].Raw, line.Content)
100+
}
101+
}
102+
103+
func TestStoreRawLineUsesParsedFieldsButKeepsRawText(t *testing.T) {
104+
s := newTestStore(t)
105+
ts := time.Date(2026, 3, 10, 21, 0, 0, 0, time.UTC)
106+
line := &logsource.LogLine{
107+
LineNumber: 3,
108+
Content: "level=info service=auth-api msg=\"user user_456 authenticated\"",
109+
}
110+
111+
outcome, err := StoreRawLine(context.Background(), s, line, stubParser{
112+
parse: func(context.Context, string) (*ParseResult, error) {
113+
return &ParseResult{
114+
Timestamp: &ts,
115+
Attrs: map[string]string{
116+
"level": "info",
117+
"service": "auth-api",
118+
},
119+
Inferred: &event.Inferred{
120+
Pattern: "user <*> authenticated",
121+
Entity: "auth-api",
122+
},
123+
Labels: map[string]string{
124+
"pattern": "user-authenticated",
125+
},
126+
}, nil
127+
},
128+
})
129+
if err != nil {
130+
t.Fatalf("StoreRawLine: %v", err)
131+
}
132+
133+
if outcome.Event.Text != line.Content {
134+
t.Fatalf("Event.Text: got %q, want %q", outcome.Event.Text, line.Content)
135+
}
136+
if outcome.Event.Timestamp == nil || !outcome.Event.Timestamp.Equal(ts) {
137+
t.Fatalf("Event.Timestamp: got %v, want %v", outcome.Event.Timestamp, ts)
138+
}
139+
if outcome.Event.Attrs["service"] != "auth-api" {
140+
t.Fatalf("Event.Attrs[service]: got %q, want %q", outcome.Event.Attrs["service"], "auth-api")
141+
}
142+
if outcome.Event.Inferred == nil || outcome.Event.Inferred.Pattern != "user <*> authenticated" {
143+
t.Fatalf("Event.Inferred: got %+v", outcome.Event.Inferred)
144+
}
145+
if outcome.LogEntry.Raw != line.Content {
146+
t.Fatalf("LogEntry.Raw: got %q, want %q", outcome.LogEntry.Raw, line.Content)
147+
}
148+
if outcome.LogEntry.Timestamp != ts {
149+
t.Fatalf("LogEntry.Timestamp: got %v, want %v", outcome.LogEntry.Timestamp, ts)
150+
}
151+
if outcome.LogEntry.Labels["pattern"] != "user-authenticated" {
152+
t.Fatalf("LogEntry.Labels[pattern]: got %q, want %q", outcome.LogEntry.Labels["pattern"], "user-authenticated")
153+
}
154+
155+
stored, err := s.QueryLogs(context.Background(), store.QueryOpts{})
156+
if err != nil {
157+
t.Fatalf("QueryLogs: %v", err)
158+
}
159+
if len(stored) != 1 {
160+
t.Fatalf("stored entries: got %d, want 1", len(stored))
161+
}
162+
if stored[0].Raw != line.Content {
163+
t.Fatalf("stored raw: got %q, want %q", stored[0].Raw, line.Content)
164+
}
165+
if stored[0].Labels["pattern"] != "user-authenticated" {
166+
t.Fatalf("stored labels[pattern]: got %q, want %q", stored[0].Labels["pattern"], "user-authenticated")
167+
}
168+
}

0 commit comments

Comments
 (0)