feat: modelo Log y CRUD en fn_operations

Tipo Log con niveles debug/info/warn/error, source, entity_id y execution_id
opcionales. Migración 003_logs.sql y funciones InsertLog, GetLog, ListLogs
con filtros combinables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 17:31:03 +02:00
parent add09c2faa
commit 169cb0853b
3 changed files with 125 additions and 0 deletions
+89
View File
@@ -814,3 +814,92 @@ func scanAssertionResults(rows *sql.Rows) ([]AssertionResult, error) {
}
return result, nil
}
// --- Log CRUD ---
// InsertLog inserts a log entry.
func (db *DB) InsertLog(l *Log) error {
if l.CreatedAt.IsZero() {
l.CreatedAt = time.Now().UTC()
}
_, err := db.conn.Exec(`
INSERT INTO logs (id, level, source, entity_id, execution_id, message, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
l.ID, string(l.Level), l.Source, l.EntityID, l.ExecutionID,
l.Message, marshalJSON(l.Metadata), l.CreatedAt.Format(time.RFC3339),
)
return err
}
// GetLog returns a log entry by ID.
func (db *DB) GetLog(id string) (*Log, error) {
row := db.conn.QueryRow(`
SELECT id, level, source, entity_id, execution_id, message, metadata, created_at
FROM logs WHERE id = ?`, id)
var l Log
var metadataJSON, createdAt string
err := row.Scan(&l.ID, &l.Level, &l.Source, &l.EntityID, &l.ExecutionID,
&l.Message, &metadataJSON, &createdAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scanning log: %w", err)
}
l.Metadata = unmarshalJSON(metadataJSON)
l.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return &l, nil
}
// ListLogs returns logs filtered by level, source, entity, and/or execution.
func (db *DB) ListLogs(level LogLevel, source, entityID, executionID string, limit int) ([]Log, error) {
where := []string{}
args := []any{}
if level != "" {
where = append(where, "level = ?")
args = append(args, string(level))
}
if source != "" {
where = append(where, "source = ?")
args = append(args, source)
}
if entityID != "" {
where = append(where, "entity_id = ?")
args = append(args, entityID)
}
if executionID != "" {
where = append(where, "execution_id = ?")
args = append(args, executionID)
}
q := `SELECT id, level, source, entity_id, execution_id, message, metadata, created_at FROM logs`
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
q += " ORDER BY created_at DESC"
if limit > 0 {
q += fmt.Sprintf(" LIMIT %d", limit)
}
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []Log
for rows.Next() {
var l Log
var metadataJSON, createdAt string
if err := rows.Scan(&l.ID, &l.Level, &l.Source, &l.EntityID, &l.ExecutionID,
&l.Message, &metadataJSON, &createdAt); err != nil {
return nil, fmt.Errorf("scanning log: %w", err)
}
l.Metadata = unmarshalJSON(metadataJSON)
l.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
result = append(result, l)
}
return result, nil
}