Files
silo-server/internal/opslog/repo.go
T

207 lines
5.7 KiB
Go

package opslog
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type EntryRow struct {
ID int64 `json:"id"`
Timestamp time.Time `json:"timestamp"`
Level string `json:"level"`
Component string `json:"component"`
Message string `json:"message"`
RequestID string `json:"request_id,omitempty"`
UserID *int `json:"user_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
PlaybackSessionID string `json:"playback_session_id,omitempty"`
ClientIP string `json:"client_ip,omitempty"`
NodeID string `json:"node_id,omitempty"`
Attrs map[string]any `json:"attrs,omitempty"`
}
type ListOptions struct {
From *time.Time
To *time.Time
Level string
Component string
NodeID string
RequestID string
UserID *int
SessionID string
PlaybackSessionID string
Query string
Limit int
Cursor string
}
type ListResult struct {
Entries []EntryRow `json:"entries"`
NextCursor string `json:"next_cursor,omitempty"`
}
type Repo struct {
pool *pgxpool.Pool
}
func NewRepo(pool *pgxpool.Pool) *Repo {
return &Repo{pool: pool}
}
func (r *Repo) List(ctx context.Context, opts ListOptions) (ListResult, error) {
limit := opts.Limit
if limit <= 0 || limit > 200 {
limit = 100
}
conditions := []string{"1=1"}
args := make([]any, 0, 12)
argIdx := 1
if opts.From != nil {
conditions = append(conditions, fmt.Sprintf("timestamp >= $%d", argIdx))
args = append(args, *opts.From)
argIdx++
}
if opts.To != nil {
conditions = append(conditions, fmt.Sprintf("timestamp <= $%d", argIdx))
args = append(args, *opts.To)
argIdx++
}
if opts.Level != "" {
conditions = append(conditions, fmt.Sprintf("level = $%d", argIdx))
args = append(args, strings.ToLower(opts.Level))
argIdx++
}
if opts.Component != "" {
conditions = append(conditions, fmt.Sprintf("component = $%d", argIdx))
args = append(args, opts.Component)
argIdx++
}
if opts.NodeID != "" {
conditions = append(conditions, fmt.Sprintf("node_id = $%d", argIdx))
args = append(args, opts.NodeID)
argIdx++
}
if opts.RequestID != "" {
conditions = append(conditions, fmt.Sprintf("request_id = $%d", argIdx))
args = append(args, opts.RequestID)
argIdx++
}
if opts.UserID != nil {
conditions = append(conditions, fmt.Sprintf("user_id = $%d", argIdx))
args = append(args, *opts.UserID)
argIdx++
}
if opts.SessionID != "" {
conditions = append(conditions, fmt.Sprintf("session_id = $%d", argIdx))
args = append(args, opts.SessionID)
argIdx++
}
if opts.PlaybackSessionID != "" {
conditions = append(conditions, fmt.Sprintf("playback_session_id = $%d", argIdx))
args = append(args, opts.PlaybackSessionID)
argIdx++
}
if opts.Query != "" {
conditions = append(conditions, fmt.Sprintf("message ILIKE $%d", argIdx))
args = append(args, "%"+opts.Query+"%")
argIdx++
}
if opts.Cursor != "" {
cursorTs, cursorID, err := decodeCursor(opts.Cursor)
if err != nil {
return ListResult{}, err
}
conditions = append(conditions, fmt.Sprintf("(timestamp, id) < ($%d, $%d)", argIdx, argIdx+1))
args = append(args, cursorTs, cursorID)
argIdx += 2
}
query := fmt.Sprintf(`
SELECT id, timestamp, level, component, message, COALESCE(request_id, ''), user_id, COALESCE(session_id, ''), COALESCE(playback_session_id, ''),
COALESCE(client_ip::text, ''), COALESCE(node_id, ''), attrs
FROM operational_logs
WHERE %s
ORDER BY timestamp DESC, id DESC
LIMIT $%d
`, strings.Join(conditions, " AND "), argIdx)
args = append(args, limit+1)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return ListResult{}, fmt.Errorf("list operational logs: %w", err)
}
defer rows.Close()
entries := make([]EntryRow, 0, limit+1)
for rows.Next() {
var entry EntryRow
var attrsJSON []byte
if err := rows.Scan(
&entry.ID,
&entry.Timestamp,
&entry.Level,
&entry.Component,
&entry.Message,
&entry.RequestID,
&entry.UserID,
&entry.SessionID,
&entry.PlaybackSessionID,
&entry.ClientIP,
&entry.NodeID,
&attrsJSON,
); err != nil {
return ListResult{}, fmt.Errorf("scan operational log row: %w", err)
}
if len(attrsJSON) > 0 {
if err := json.Unmarshal(attrsJSON, &entry.Attrs); err != nil {
return ListResult{}, fmt.Errorf("decode operational log attrs: %w", err)
}
}
entries = append(entries, entry)
}
if err := rows.Err(); err != nil {
return ListResult{}, fmt.Errorf("iterate operational logs: %w", err)
}
result := ListResult{}
if len(entries) > limit {
last := entries[limit-1]
result.NextCursor = encodeCursor(last.Timestamp, last.ID)
entries = entries[:limit]
}
result.Entries = entries
return result, nil
}
func encodeCursor(ts time.Time, id int64) string {
return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%d|%d", ts.UnixNano(), id)))
}
func decodeCursor(cursor string) (time.Time, int64, error) {
raw, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return time.Time{}, 0, fmt.Errorf("decode cursor: %w", err)
}
parts := strings.Split(string(raw), "|")
if len(parts) != 2 {
return time.Time{}, 0, fmt.Errorf("invalid cursor")
}
var nanos int64
var id int64
if _, err := fmt.Sscanf(parts[0], "%d", &nanos); err != nil {
return time.Time{}, 0, fmt.Errorf("parse cursor timestamp: %w", err)
}
if _, err := fmt.Sscanf(parts[1], "%d", &id); err != nil {
return time.Time{}, 0, fmt.Errorf("parse cursor id: %w", err)
}
return time.Unix(0, nanos).UTC(), id, nil
}