207 lines
5.7 KiB
Go
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
|
|
}
|