mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
7d71f1e4e1
- New internal/repository/logstore abstraction: exported domain interfaces (AccessLogStore/ObservabilityStore/UserAccessLogStore/StatusStore), config-driven provider (Active/Build/Migrating/SetConfigReader), GORM implementation for PostgreSQL/SQLite (incl. hourly rollups computed in real time, migration listers, PG partition maintenance), and a ClickHouse wrapper preserving the native batch path; repository facade delegates to logstore; import-lint test enforces apps never import analyticsrepo. - ClickHouse is now optional: the log DB is either the main DB (postgres when database.enabled, else sqlite) or clickhouse; boot validation + first-run seed; log_database / log_db_migration are protected keys. - New user task 切换日志数据库 (of_log_db_switch): freeze log writes, drain batch writers, copy all 6 raw log tables by id (preserving IDs) with target-partition pre-creation for PG, flip log_database on success, clear the freeze flag on failure. - Per-store retention (log_retention_days_*) with expiry cleanup folded into the daily system_cleanup task; legacy database_auto_cleanup_* and of_database_auto_cleanup decommissioned. - goose migrations: 6 log tables in PG (2 monthly-partitioned) + SQLite, retention config seeds, schedule cleanup; GET /api/v1/admin/status/log-database endpoint; frontend retention settings, switch-task UI and status badge; changelog and docs updated. docs(plan): log database decoupling implementation plan docs(design): log database decoupling design (ClickHouse optional)
301 lines
9.7 KiB
Go
301 lines
9.7 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package analytics
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
|
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
|
analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics"
|
|
)
|
|
|
|
// NodeAccessLogRegionCount aggregates access log regions.
|
|
type NodeAccessLogRegionCount = analyticsmodel.NodeAccessLogRegionCount
|
|
|
|
func nodeAccessLogConn() (driver.Conn, error) {
|
|
if db.ChConn == nil {
|
|
return nil, fmt.Errorf("clickhouse connection is not initialized")
|
|
}
|
|
return db.ChConn, nil
|
|
}
|
|
|
|
// ListNodeAccessLogs returns access logs matching filter.
|
|
func ListNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter) ([]analyticsmodel.NodeAccessLog, error) {
|
|
conn, err := nodeAccessLogConn()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
clause, args := buildNodeAccessLogFilterClause(filter)
|
|
tableName := nodeAccessLogTableName()
|
|
sql := fmt.Sprintf(`
|
|
SELECT id, node_id, logged_at, remote_addr, region, host, path, user_agent, cache_status, status_code, bytes_sent, request_length, request_time_ms, created_at
|
|
FROM %s
|
|
WHERE %s
|
|
ORDER BY %s`, tableName, clause, nodeAccessLogOrderClause(filter.SortBy, filter.SortOrder))
|
|
if filter.PageSize > 0 {
|
|
if filter.Page < 0 {
|
|
filter.Page = 0
|
|
}
|
|
sql += clickHouseLimitOffsetClause
|
|
args = append(args, filter.PageSize, filter.Page*filter.PageSize)
|
|
}
|
|
rows, err := conn.Query(ctx, sql, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list node access logs: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
return scanNodeAccessLogRows(rows)
|
|
}
|
|
|
|
//nolint:dupl // scan shapes differ by model fields; shared helper would obscure CH column mapping
|
|
func scanNodeAccessLogRows(rows driver.Rows) ([]analyticsmodel.NodeAccessLog, error) {
|
|
var result []analyticsmodel.NodeAccessLog
|
|
for rows.Next() {
|
|
var item analyticsmodel.NodeAccessLog
|
|
if err := rows.Scan(
|
|
&item.ID,
|
|
&item.NodeID,
|
|
&item.LoggedAt,
|
|
&item.RemoteAddr,
|
|
&item.Region,
|
|
&item.Host,
|
|
&item.Path,
|
|
&item.UserAgent,
|
|
&item.CacheStatus,
|
|
&item.StatusCode,
|
|
&item.BytesSent,
|
|
&item.RequestLength,
|
|
&item.RequestTimeMs,
|
|
&item.CreatedAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan node access log row: %w", err)
|
|
}
|
|
item.LoggedAt = item.LoggedAt.UTC()
|
|
item.CreatedAt = item.CreatedAt.UTC()
|
|
result = append(result, item)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// CountNodeAccessLogs returns total records, distinct IPs, and total bytes sent matching filter.
|
|
func CountNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter) (int64, int64, int64, error) {
|
|
conn, err := nodeAccessLogConn()
|
|
if err != nil {
|
|
return 0, 0, 0, err
|
|
}
|
|
clause, args := buildNodeAccessLogFilterClause(filter)
|
|
tableName := nodeAccessLogTableName()
|
|
|
|
countSQL := fmt.Sprintf(`
|
|
SELECT
|
|
count() AS total_records,
|
|
uniqExactIf(remote_addr, remote_addr != '') AS total_ips,
|
|
sum(bytes_sent) AS total_bytes
|
|
FROM %s
|
|
WHERE %s`, tableName, clause)
|
|
var totalRecords, totalIPs, totalBytes uint64
|
|
if err := conn.QueryRow(ctx, countSQL, args...).Scan(&totalRecords, &totalIPs, &totalBytes); err != nil {
|
|
return 0, 0, 0, fmt.Errorf("count node access logs: %w", err)
|
|
}
|
|
return safeInt64Count(totalRecords), safeInt64Count(totalIPs), safeInt64Count(totalBytes), nil
|
|
}
|
|
|
|
// RegionCountsNodeAccessLogs returns region counts for a node since a time.
|
|
func RegionCountsNodeAccessLogs(ctx context.Context, nodeID string, since time.Time, limit int) ([]NodeAccessLogRegionCount, error) {
|
|
conn, err := nodeAccessLogConn()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
filter := NodeAccessLogFilter{NodeID: nodeID, Since: since}
|
|
clause, args := buildNodeAccessLogFilterClause(filter)
|
|
tableName := nodeAccessLogTableName()
|
|
sql := fmt.Sprintf(`
|
|
SELECT trim(region) AS trimmed_region, count() AS count
|
|
FROM %s
|
|
WHERE %s AND trim(region) != ''
|
|
GROUP BY trimmed_region
|
|
ORDER BY count DESC, trimmed_region ASC`, tableName, clause)
|
|
if limit > 0 {
|
|
sql += clickHouseLimitClause
|
|
args = append(args, limit)
|
|
}
|
|
rows, err := conn.Query(ctx, sql, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("region counts node access logs: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var result []NodeAccessLogRegionCount
|
|
for rows.Next() {
|
|
var (
|
|
region string
|
|
count uint64
|
|
)
|
|
if err := rows.Scan(®ion, &count); err != nil {
|
|
return nil, fmt.Errorf("scan region count row: %w", err)
|
|
}
|
|
result = append(result, NodeAccessLogRegionCount{
|
|
Region: region,
|
|
Count: safeInt64Count(count),
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// NodeAccessLogTrafficSummary is a window-level access log traffic summary.
|
|
type NodeAccessLogTrafficSummary = analyticsmodel.NodeAccessLogTrafficSummary
|
|
|
|
// NodeAccessLogValueCount is a grouped value count (status_code, host, ...).
|
|
type NodeAccessLogValueCount = analyticsmodel.NodeAccessLogValueCount
|
|
|
|
// NodeAccessLogNodeAggregate is per-node traffic over a window.
|
|
type NodeAccessLogNodeAggregate = analyticsmodel.NodeAccessLogNodeAggregate
|
|
|
|
// TrafficSummaryNodeAccessLogs returns request/error/UV/bytes/node counts for the filter.
|
|
func TrafficSummaryNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter) (NodeAccessLogTrafficSummary, error) {
|
|
conn, err := nodeAccessLogConn()
|
|
if err != nil {
|
|
return NodeAccessLogTrafficSummary{}, err
|
|
}
|
|
clause, args := buildNodeAccessLogFilterClause(filter)
|
|
tableName := nodeAccessLogTableName()
|
|
sql := fmt.Sprintf(`
|
|
SELECT
|
|
count() AS request_count,
|
|
countIf(status_code >= 500) AS error_count,
|
|
uniqExactIf(remote_addr, remote_addr != '') AS unique_ips,
|
|
sum(bytes_sent) AS bytes_sent,
|
|
sum(request_length) AS request_length,
|
|
uniqExactIf(node_id, node_id != '') AS node_count
|
|
FROM %s
|
|
WHERE %s`, tableName, clause)
|
|
var requestCount, errorCount, uniqueIPs, bytesSent, requestLength, nodeCount uint64
|
|
if err := conn.QueryRow(ctx, sql, args...).Scan(
|
|
&requestCount, &errorCount, &uniqueIPs, &bytesSent, &requestLength, &nodeCount,
|
|
); err != nil {
|
|
return NodeAccessLogTrafficSummary{}, fmt.Errorf("traffic summary node access logs: %w", err)
|
|
}
|
|
return NodeAccessLogTrafficSummary{
|
|
RequestCount: safeInt64Count(requestCount),
|
|
ErrorCount: safeInt64Count(errorCount),
|
|
UniqueIPCount: safeInt64Count(uniqueIPs),
|
|
BytesSent: safeInt64Count(bytesSent),
|
|
RequestLength: safeInt64Count(requestLength),
|
|
NodeCount: safeInt64Count(nodeCount),
|
|
}, nil
|
|
}
|
|
|
|
// ValueCountsNodeAccessLogs groups logs by a single dimension column.
|
|
// Allowed columns: status_code, host, path, remote_addr, user_agent.
|
|
func ValueCountsNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter, column string, limit int) ([]NodeAccessLogValueCount, error) {
|
|
conn, err := nodeAccessLogConn()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
col := strings.TrimSpace(strings.ToLower(column))
|
|
valueExpr, ok := nodeAccessLogValueCountExpr(col)
|
|
if !ok {
|
|
return nil, fmt.Errorf("unsupported value count column: %s", column)
|
|
}
|
|
clause, args := buildNodeAccessLogFilterClause(filter)
|
|
tableName := nodeAccessLogTableName()
|
|
filterExpr := valueExpr + " != ''"
|
|
if col == nodeAccessLogColumnStatusCode {
|
|
filterExpr = "status_code >= 0"
|
|
}
|
|
sql := fmt.Sprintf(`
|
|
SELECT %s AS value, count() AS count
|
|
FROM %s
|
|
WHERE %s AND %s
|
|
GROUP BY value
|
|
ORDER BY count DESC, value ASC`, valueExpr, tableName, clause, filterExpr)
|
|
if limit > 0 {
|
|
sql += clickHouseLimitClause
|
|
args = append(args, limit)
|
|
}
|
|
rows, err := conn.Query(ctx, sql, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("value counts node access logs: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
var result []NodeAccessLogValueCount
|
|
for rows.Next() {
|
|
var (
|
|
value string
|
|
count uint64
|
|
)
|
|
if err := rows.Scan(&value, &count); err != nil {
|
|
return nil, fmt.Errorf("scan value count row: %w", err)
|
|
}
|
|
result = append(result, NodeAccessLogValueCount{
|
|
Value: value,
|
|
Count: safeInt64Count(count),
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func nodeAccessLogValueCountExpr(column string) (string, bool) {
|
|
switch column {
|
|
case nodeAccessLogColumnStatusCode:
|
|
return "toString(" + nodeAccessLogColumnStatusCode + ")", true
|
|
case nodeAccessLogColumnHost:
|
|
return "trim(" + nodeAccessLogColumnHost + ")", true
|
|
case nodeAccessLogColumnPath:
|
|
return "trim(" + nodeAccessLogColumnPath + ")", true
|
|
case nodeAccessLogColumnRemoteAddr:
|
|
return "trim(" + nodeAccessLogColumnRemoteAddr + ")", true
|
|
case nodeAccessLogColumnUserAgent:
|
|
return "trim(" + nodeAccessLogColumnUserAgent + ")", true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// NodeAggregatesNodeAccessLogs returns per-node request/error/UV aggregates.
|
|
func NodeAggregatesNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter) ([]NodeAccessLogNodeAggregate, error) {
|
|
conn, err := nodeAccessLogConn()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
clause, args := buildNodeAccessLogFilterClause(filter)
|
|
tableName := nodeAccessLogTableName()
|
|
sql := fmt.Sprintf(`
|
|
SELECT
|
|
node_id,
|
|
count() AS request_count,
|
|
countIf(status_code >= 500) AS error_count,
|
|
uniqExactIf(remote_addr, remote_addr != '') AS unique_ips
|
|
FROM %s
|
|
WHERE %s AND node_id != ''
|
|
GROUP BY node_id
|
|
ORDER BY request_count DESC, node_id ASC`, tableName, clause)
|
|
rows, err := conn.Query(ctx, sql, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("node aggregates node access logs: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
var result []NodeAccessLogNodeAggregate
|
|
for rows.Next() {
|
|
var (
|
|
nodeID string
|
|
requestCount, errorCount, uniqueIPs uint64
|
|
)
|
|
if err := rows.Scan(&nodeID, &requestCount, &errorCount, &uniqueIPs); err != nil {
|
|
return nil, fmt.Errorf("scan node aggregate row: %w", err)
|
|
}
|
|
result = append(result, NodeAccessLogNodeAggregate{
|
|
NodeID: nodeID,
|
|
RequestCount: safeInt64Count(requestCount),
|
|
ErrorCount: safeInt64Count(errorCount),
|
|
UniqueIPCount: safeInt64Count(uniqueIPs),
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|