mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 15:46:37 +08:00
refactor(backend): rename OpenFlare directory to lowercase openflare
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package observability provides system and service level observability data collection for the agent.
|
||||
package observability
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/config"
|
||||
"Wavelet/openflare/plugins/agent/protocol"
|
||||
"Wavelet/openflare/plugins/agent/state"
|
||||
edgeobs "Wavelet/openflare/share/edge/observability"
|
||||
)
|
||||
|
||||
const nodeHealthEventInitialCapacity = 2
|
||||
|
||||
// BuildProfile collects the system profile and returns it only if the fingerprint has changed.
|
||||
func BuildProfile(cfg *config.Config, stateStore *state.Store) *protocol.NodeSystemProfile {
|
||||
profile := collectProfile(cfg)
|
||||
if profile == nil {
|
||||
return nil
|
||||
}
|
||||
fingerprint := fingerprintProfile(profile)
|
||||
if stateStore == nil {
|
||||
return profile
|
||||
}
|
||||
snapshot, err := stateStore.Load()
|
||||
if err != nil {
|
||||
return profile
|
||||
}
|
||||
if snapshot.LastProfileFingerprint == fingerprint {
|
||||
return nil
|
||||
}
|
||||
snapshot.LastProfileFingerprint = fingerprint
|
||||
if err = stateStore.Save(snapshot); err != nil {
|
||||
return profile
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// BuildSnapshot captures current system metrics and returns a metric snapshot.
|
||||
func BuildSnapshot(cfg *config.Config, stateStore *state.Store) *protocol.NodeMetricSnapshot {
|
||||
now := time.Now().UTC()
|
||||
metric := &protocol.NodeMetricSnapshot{
|
||||
CapturedAtUnix: now.Unix(),
|
||||
}
|
||||
|
||||
memTotal, memUsed := edgeobs.ReadMemInfo()
|
||||
metric.MemoryTotalBytes = memTotal
|
||||
metric.MemoryUsedBytes = memUsed
|
||||
|
||||
storageTotal, storageUsed := edgeobs.StatFilesystem(cfg.DataDir)
|
||||
metric.StorageTotalBytes = storageTotal
|
||||
metric.StorageUsedBytes = storageUsed
|
||||
|
||||
// Host NIC totals are not collected (product no longer surfaces host NIC trends).
|
||||
metric.DiskReadBytes, metric.DiskWriteBytes = edgeobs.ReadLinuxDiskTotals()
|
||||
|
||||
if stateStore == nil {
|
||||
return metric
|
||||
}
|
||||
|
||||
totalCPU, idleCPU := edgeobs.ReadLinuxCPUStat()
|
||||
snapshot, err := stateStore.Load()
|
||||
if err != nil {
|
||||
return metric
|
||||
}
|
||||
if snapshot.LastCPUStatTotal > 0 && totalCPU > snapshot.LastCPUStatTotal && idleCPU >= snapshot.LastCPUStatIdle {
|
||||
deltaTotal := totalCPU - snapshot.LastCPUStatTotal
|
||||
deltaIdle := idleCPU - snapshot.LastCPUStatIdle
|
||||
if deltaTotal > 0 && deltaIdle <= deltaTotal {
|
||||
metric.CPUUsagePercent = (float64(deltaTotal-deltaIdle) / float64(deltaTotal)) * 100
|
||||
}
|
||||
}
|
||||
snapshot.LastCPUStatTotal = totalCPU
|
||||
snapshot.LastCPUStatIdle = idleCPU
|
||||
snapshot.LastMetricAtUnix = now.Unix()
|
||||
_ = stateStore.Save(snapshot)
|
||||
|
||||
return metric
|
||||
}
|
||||
|
||||
// BuildEdgeHealth builds the edge_health payload from a local probe and node status.
|
||||
func BuildEdgeHealth(probe *EdgeHealthSnapshot, openrestyStatus, openrestyMessage string) *protocol.NodeEdgeHealth {
|
||||
status := strings.TrimSpace(openrestyStatus)
|
||||
message := strings.TrimSpace(openrestyMessage)
|
||||
if probe == nil {
|
||||
if status == "" {
|
||||
return nil
|
||||
}
|
||||
return &protocol.NodeEdgeHealth{
|
||||
CapturedAtUnix: time.Now().UTC().Unix(),
|
||||
Status: status,
|
||||
Message: message,
|
||||
Connections: 0,
|
||||
}
|
||||
}
|
||||
|
||||
captured := probe.CapturedAtUnix
|
||||
if captured <= 0 {
|
||||
captured = time.Now().UTC().Unix()
|
||||
}
|
||||
if status == "" {
|
||||
status = protocol.OpenrestyStatusUnknown
|
||||
if probe.OK {
|
||||
status = protocol.OpenrestyStatusHealthy
|
||||
}
|
||||
}
|
||||
return &protocol.NodeEdgeHealth{
|
||||
CapturedAtUnix: captured,
|
||||
Status: status,
|
||||
Message: message,
|
||||
Connections: probe.Connections,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildHealthEvents converts system snapshot health state into a list of health events.
|
||||
func BuildHealthEvents(snapshot *state.Snapshot) []protocol.NodeHealthEvent {
|
||||
if snapshot == nil {
|
||||
return []protocol.NodeHealthEvent{}
|
||||
}
|
||||
events := make([]protocol.NodeHealthEvent, 0, nodeHealthEventInitialCapacity)
|
||||
nowUnix := time.Now().UTC().Unix()
|
||||
if strings.TrimSpace(snapshot.OpenrestyStatus) == protocol.OpenrestyStatusUnhealthy {
|
||||
events = append(events, protocol.NodeHealthEvent{
|
||||
EventType: "openresty_unhealthy",
|
||||
Severity: "critical",
|
||||
Message: strings.TrimSpace(snapshot.OpenrestyMessage),
|
||||
TriggeredAtUnix: nowUnix,
|
||||
})
|
||||
}
|
||||
if strings.TrimSpace(snapshot.LastError) != "" {
|
||||
events = append(events, protocol.NodeHealthEvent{
|
||||
EventType: "sync_error",
|
||||
Severity: "warning",
|
||||
Message: strings.TrimSpace(snapshot.LastError),
|
||||
TriggeredAtUnix: nowUnix,
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func collectProfile(cfg *config.Config) *protocol.NodeSystemProfile {
|
||||
hostname, _ := os.Hostname()
|
||||
osName, osVersion := edgeobs.ReadLinuxOSRelease()
|
||||
kernelVersion := edgeobs.ReadFirstLine("/proc/sys/kernel/osrelease")
|
||||
cpuModel := edgeobs.ReadLinuxCPUModel()
|
||||
totalMemory, _ := edgeobs.ReadMemInfo()
|
||||
totalDisk, _ := edgeobs.StatFilesystem(cfg.DataDir)
|
||||
uptimeSeconds := edgeobs.ReadLinuxUptimeSeconds()
|
||||
|
||||
return &protocol.NodeSystemProfile{
|
||||
Hostname: strings.TrimSpace(hostname),
|
||||
OSName: osName,
|
||||
OSVersion: osVersion,
|
||||
KernelVersion: kernelVersion,
|
||||
Architecture: runtime.GOARCH,
|
||||
CPUModel: cpuModel,
|
||||
CPUCores: runtime.NumCPU(),
|
||||
TotalMemoryBytes: totalMemory,
|
||||
TotalDiskBytes: totalDisk,
|
||||
UptimeSeconds: uptimeSeconds,
|
||||
ReportedAtUnix: time.Now().UTC().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func fingerprintProfile(profile *protocol.NodeSystemProfile) string {
|
||||
cloned := *profile
|
||||
cloned.UptimeSeconds = 0
|
||||
cloned.ReportedAtUnix = 0
|
||||
raw, err := json.Marshal(&cloned)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/config"
|
||||
)
|
||||
|
||||
const openRestyObservabilityPath = "/openflare/observability"
|
||||
|
||||
// EdgeHealthSnapshot is the L2 OpenResty health probe result.
|
||||
type EdgeHealthSnapshot struct {
|
||||
OK bool
|
||||
CapturedAtUnix int64
|
||||
Connections int64
|
||||
Reading int64
|
||||
Writing int64
|
||||
Waiting int64
|
||||
}
|
||||
|
||||
type openRestyObservabilityResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
CapturedAtUnix int64 `json:"captured_at_unix"`
|
||||
Connections struct {
|
||||
Active int64 `json:"active"`
|
||||
Reading int64 `json:"reading"`
|
||||
Writing int64 `json:"writing"`
|
||||
Waiting int64 `json:"waiting"`
|
||||
} `json:"connections"`
|
||||
}
|
||||
|
||||
// CollectEdgeHealth probes the local OpenResty observability JSON endpoint.
|
||||
func CollectEdgeHealth(ctx context.Context, cfg *config.Config) *EdgeHealthSnapshot {
|
||||
if cfg == nil || cfg.OpenrestyObservabilityPort <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
baseURL := fmt.Sprintf("http://127.0.0.1:%d", cfg.OpenrestyObservabilityPort)
|
||||
client := &http.Client{Timeout: 1500 * time.Millisecond}
|
||||
|
||||
var resp openRestyObservabilityResponse
|
||||
if err := fetchLocalJSON(ctx, client, baseURL+openRestyObservabilityPath, &resp); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
captured := resp.CapturedAtUnix
|
||||
if captured <= 0 {
|
||||
captured = time.Now().UTC().Unix()
|
||||
}
|
||||
return &EdgeHealthSnapshot{
|
||||
OK: resp.OK,
|
||||
CapturedAtUnix: captured,
|
||||
Connections: resp.Connections.Active,
|
||||
Reading: resp.Connections.Reading,
|
||||
Writing: resp.Connections.Writing,
|
||||
Waiting: resp.Connections.Waiting,
|
||||
}
|
||||
}
|
||||
|
||||
func fetchLocalJSON(ctx context.Context, client *http.Client, url string, target any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected local observability status: %s", resp.Status)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(target)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/config"
|
||||
)
|
||||
|
||||
func TestCollectEdgeHealth(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/openflare/observability" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true,"captured_at_unix":1710403200,"connections":{"active":12,"reading":1,"writing":2,"waiting":9}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
health := CollectEdgeHealth(context.Background(), &config.Config{
|
||||
OpenrestyObservabilityPort: mustPort(server.URL),
|
||||
})
|
||||
if health == nil {
|
||||
t.Fatal("expected edge health")
|
||||
}
|
||||
if !health.OK || health.Connections != 12 {
|
||||
t.Fatalf("unexpected health: %+v", health)
|
||||
}
|
||||
}
|
||||
|
||||
func mustPort(rawURL string) int {
|
||||
u := rawURL
|
||||
idx := stringsLastColon(u)
|
||||
if idx < 0 {
|
||||
return 0
|
||||
}
|
||||
var port int
|
||||
for _, ch := range u[idx+1:] {
|
||||
if ch < '0' || ch > '9' {
|
||||
break
|
||||
}
|
||||
port = port*10 + int(ch-'0')
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
func stringsLastColon(s string) int {
|
||||
for i := len(s) - 1; i >= 0; i-- {
|
||||
if s[i] == ':' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestCollectEdgeHealthHandlesUnavailableEndpoint(t *testing.T) {
|
||||
if health := CollectEdgeHealth(context.Background(), &config.Config{
|
||||
OpenrestyObservabilityPort: 1,
|
||||
}); health != nil {
|
||||
t.Fatalf("expected nil health, got %+v", health)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package observability
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/config"
|
||||
"Wavelet/openflare/plugins/agent/protocol"
|
||||
"Wavelet/openflare/plugins/agent/state"
|
||||
)
|
||||
|
||||
type accessLogRecord struct {
|
||||
Timestamp string `json:"ts"`
|
||||
Host string `json:"host"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
Path string `json:"path"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
CacheStatus string `json:"cache_status"`
|
||||
Status int `json:"status"`
|
||||
BytesSent int64 `json:"bytes_sent"`
|
||||
RequestLength int64 `json:"request_length"`
|
||||
RequestTime float64 `json:"request_time"`
|
||||
}
|
||||
|
||||
const (
|
||||
combinedAccessLogMatchGroupCount = 5
|
||||
// requestTimeSecondsToMs converts OpenResty $request_time (seconds float) to ms.
|
||||
requestTimeSecondsToMs = 1000.0
|
||||
// roundHalfUp is added before int64 truncate to round to nearest millisecond.
|
||||
roundHalfUp = 0.5
|
||||
)
|
||||
|
||||
var combinedAccessLogPattern = regexp.MustCompile(`^(\S+)\s+\S+\s+\S+\s+\[([^]]+)]\s+"\S+\s+(\S+)(?:\s+[^"]*)?"\s+(\d{3})\s+\S+`)
|
||||
|
||||
// trafficAggregate collects access-log facts for the current heartbeat window.
|
||||
// Pre-aggregation (UV/TopN/TrafficReport) is intentionally not built.
|
||||
type trafficAggregate struct {
|
||||
logs []protocol.NodeAccessLog
|
||||
}
|
||||
|
||||
// CollectAccessLogs tails access.log and returns L1 fact rows for the current heartbeat.
|
||||
func CollectAccessLogs(cfg *config.Config, stateStore *state.Store) []protocol.NodeAccessLog {
|
||||
if cfg == nil || stateStore == nil {
|
||||
return nil
|
||||
}
|
||||
aggregate := readAccessLogDelta(cfg, stateStore)
|
||||
if aggregate == nil {
|
||||
return nil
|
||||
}
|
||||
return aggregate.accessLogs()
|
||||
}
|
||||
|
||||
func readAccessLogDelta(cfg *config.Config, stateStore *state.Store) *trafficAggregate {
|
||||
snapshot, err := stateStore.Load()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
logPath := managedAccessLogPath(cfg)
|
||||
file, err := os.Open(logPath) //nolint:gosec // path is the configured managed access log location
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if snapshot.AccessLogOffset != 0 {
|
||||
snapshot.AccessLogOffset = 0
|
||||
_ = stateStore.Save(snapshot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer func(file *os.File) {
|
||||
err := file.Close()
|
||||
if err != nil {
|
||||
slog.Error("failed to close access log file", "error", err)
|
||||
}
|
||||
}(file)
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
offset := snapshot.AccessLogOffset
|
||||
if offset < 0 || offset > info.Size() {
|
||||
offset = 0
|
||||
}
|
||||
if _, err = file.Seek(offset, io.SeekStart); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(file)
|
||||
currentOffset := offset
|
||||
aggregate := newTrafficAggregate()
|
||||
|
||||
for {
|
||||
line, readErr := reader.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
currentOffset += int64(len(line))
|
||||
aggregate.consume(line)
|
||||
}
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
snapshot.AccessLogOffset = currentOffset
|
||||
_ = stateStore.Save(snapshot)
|
||||
|
||||
return aggregate
|
||||
}
|
||||
|
||||
func managedAccessLogPath(cfg *config.Config) string {
|
||||
if cfg == nil || strings.TrimSpace(cfg.AccessLogPath) == "" {
|
||||
return ""
|
||||
}
|
||||
return cfg.AccessLogPath
|
||||
}
|
||||
|
||||
func newTrafficAggregate() *trafficAggregate {
|
||||
return &trafficAggregate{}
|
||||
}
|
||||
|
||||
func (aggregate *trafficAggregate) consume(line []byte) {
|
||||
trimmed := strings.TrimSpace(string(line))
|
||||
if trimmed == "" {
|
||||
return
|
||||
}
|
||||
|
||||
record, ok := parseAccessLogRecord(trimmed)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
aggregate.logs = append(aggregate.logs, protocol.NodeAccessLog{
|
||||
LoggedAtUnix: record.Timestamp.Unix(),
|
||||
RemoteAddr: strings.TrimSpace(record.RemoteAddr),
|
||||
Host: strings.TrimSpace(record.Host),
|
||||
Path: normalizeAccessLogPath(record.Path),
|
||||
UserAgent: strings.TrimSpace(record.UserAgent),
|
||||
CacheStatus: normalizeCacheStatus(record.CacheStatus),
|
||||
StatusCode: record.Status,
|
||||
BytesSent: record.BytesSent,
|
||||
RequestLength: record.RequestLength,
|
||||
RequestTimeMs: record.RequestTimeMs,
|
||||
})
|
||||
}
|
||||
|
||||
type parsedAccessLogRecord struct {
|
||||
Timestamp time.Time
|
||||
Host string
|
||||
RemoteAddr string
|
||||
Path string
|
||||
UserAgent string
|
||||
CacheStatus string
|
||||
Status int
|
||||
BytesSent int64
|
||||
RequestLength int64
|
||||
RequestTimeMs int64
|
||||
}
|
||||
|
||||
func parseAccessLogRecord(raw string) (parsedAccessLogRecord, bool) {
|
||||
record, ok := parseJSONAccessLogRecord(raw)
|
||||
if ok {
|
||||
return record, true
|
||||
}
|
||||
return parseCombinedAccessLogRecord(raw)
|
||||
}
|
||||
|
||||
func parseJSONAccessLogRecord(raw string) (parsedAccessLogRecord, bool) {
|
||||
var record accessLogRecord
|
||||
if err := json.Unmarshal([]byte(raw), &record); err != nil {
|
||||
return parsedAccessLogRecord{}, false
|
||||
}
|
||||
timestamp, err := parseAccessLogTime(record.Timestamp)
|
||||
if err != nil {
|
||||
return parsedAccessLogRecord{}, false
|
||||
}
|
||||
requestTimeMs := int64(0)
|
||||
if record.RequestTime > 0 {
|
||||
requestTimeMs = int64(record.RequestTime*requestTimeSecondsToMs + roundHalfUp)
|
||||
}
|
||||
return parsedAccessLogRecord{
|
||||
Timestamp: timestamp,
|
||||
Host: strings.TrimSpace(record.Host),
|
||||
RemoteAddr: strings.TrimSpace(record.RemoteAddr),
|
||||
Path: normalizeAccessLogPath(record.Path),
|
||||
UserAgent: strings.TrimSpace(record.UserAgent),
|
||||
CacheStatus: normalizeCacheStatus(record.CacheStatus),
|
||||
Status: record.Status,
|
||||
BytesSent: record.BytesSent,
|
||||
RequestLength: record.RequestLength,
|
||||
RequestTimeMs: requestTimeMs,
|
||||
}, true
|
||||
}
|
||||
|
||||
func normalizeCacheStatus(value string) string {
|
||||
// Keep OpenResty "-" as-is so details can distinguish it from a missing field.
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func parseCombinedAccessLogRecord(raw string) (parsedAccessLogRecord, bool) {
|
||||
matches := combinedAccessLogPattern.FindStringSubmatch(raw)
|
||||
if len(matches) != combinedAccessLogMatchGroupCount {
|
||||
return parsedAccessLogRecord{}, false
|
||||
}
|
||||
timestamp, err := parseAccessLogTime(matches[2])
|
||||
if err != nil {
|
||||
return parsedAccessLogRecord{}, false
|
||||
}
|
||||
status, err := strconv.Atoi(matches[4])
|
||||
if err != nil {
|
||||
return parsedAccessLogRecord{}, false
|
||||
}
|
||||
return parsedAccessLogRecord{
|
||||
Timestamp: timestamp,
|
||||
RemoteAddr: strings.TrimSpace(matches[1]),
|
||||
Path: normalizeAccessLogPath(matches[3]),
|
||||
Status: status,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (aggregate *trafficAggregate) accessLogs() []protocol.NodeAccessLog {
|
||||
if aggregate == nil || len(aggregate.logs) == 0 {
|
||||
return []protocol.NodeAccessLog{}
|
||||
}
|
||||
return append([]protocol.NodeAccessLog(nil), aggregate.logs...)
|
||||
}
|
||||
|
||||
func parseAccessLogTime(value string) (time.Time, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return time.Time{}, errors.New("empty access log time")
|
||||
}
|
||||
timestamp, err := time.Parse(time.RFC3339, trimmed)
|
||||
if err == nil {
|
||||
return timestamp, nil
|
||||
}
|
||||
return time.Parse("02/Jan/2006:15:04:05 -0700", trimmed)
|
||||
}
|
||||
|
||||
const accessLogPathMaxRunes = 100
|
||||
|
||||
func normalizeAccessLogPath(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
|
||||
return truncateAccessLogPath(trimmed)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "/") {
|
||||
return truncateAccessLogPath(trimmed)
|
||||
}
|
||||
return truncateAccessLogPath("/" + trimmed)
|
||||
}
|
||||
|
||||
func truncateAccessLogPath(value string) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= accessLogPathMaxRunes {
|
||||
return value
|
||||
}
|
||||
return string(runes[:accessLogPathMaxRunes])
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package observability
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/config"
|
||||
"Wavelet/openflare/plugins/agent/state"
|
||||
)
|
||||
|
||||
func TestCollectAccessLogsReturnsFactsOnly(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
logPath := filepath.Join(tempDir, "openflare_access.log")
|
||||
content := []byte(
|
||||
"{\"ts\":\"2026-03-14T08:00:00Z\",\"host\":\"app.example.com\",\"path\":\"/login\",\"remote_addr\":\"10.0.0.1\",\"status\":200,\"request_length\":128,\"bytes_sent\":512,\"request_time\":0.015,\"user_agent\":\"Mozilla/5.0\",\"cache_status\":\"HIT\"}\n" +
|
||||
"{\"ts\":\"2026-03-14T08:00:05Z\",\"host\":\"api.example.com\",\"path\":\"/v1/ping\",\"remote_addr\":\"10.0.0.2\",\"status\":502,\"request_length\":64,\"bytes_sent\":256,\"request_time\":0.008,\"user_agent\":\"curl/8.0\",\"cache_status\":\"MISS\"}\n",
|
||||
)
|
||||
if err := os.WriteFile(logPath, content, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
|
||||
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
|
||||
accessLogs := CollectAccessLogs(&config.Config{AccessLogPath: logPath}, stateStore)
|
||||
if len(accessLogs) != 2 {
|
||||
t.Fatalf("expected access logs, got %+v", accessLogs)
|
||||
}
|
||||
if accessLogs[0].BytesSent != 512 || accessLogs[0].RequestLength != 128 {
|
||||
t.Fatalf("unexpected first log: %+v", accessLogs[0])
|
||||
}
|
||||
if accessLogs[0].RequestTimeMs != 15 {
|
||||
t.Fatalf("request_time_ms = %d, want 15", accessLogs[0].RequestTimeMs)
|
||||
}
|
||||
if accessLogs[0].Path != "/login" || accessLogs[1].Path != "/v1/ping" {
|
||||
t.Fatalf("unexpected access log paths: %+v", accessLogs)
|
||||
}
|
||||
if accessLogs[0].UserAgent != "Mozilla/5.0" || accessLogs[1].UserAgent != "curl/8.0" {
|
||||
t.Fatalf("unexpected user agents: %+v", accessLogs)
|
||||
}
|
||||
if accessLogs[0].CacheStatus != "HIT" || accessLogs[1].CacheStatus != "MISS" {
|
||||
t.Fatalf("unexpected cache status: %+v", accessLogs)
|
||||
}
|
||||
|
||||
snapshot, err := stateStore.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if snapshot.AccessLogOffset != int64(len(content)) {
|
||||
t.Fatalf("unexpected access log offset: %d", snapshot.AccessLogOffset)
|
||||
}
|
||||
|
||||
moreLogs := CollectAccessLogs(&config.Config{AccessLogPath: logPath}, stateStore)
|
||||
if len(moreLogs) != 0 {
|
||||
t.Fatalf("expected no new logs, got %+v", moreLogs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCacheStatusKeepsDash(t *testing.T) {
|
||||
if got := normalizeCacheStatus(" - "); got != "-" {
|
||||
t.Fatalf("normalizeCacheStatus dash = %q, want -", got)
|
||||
}
|
||||
if got := normalizeCacheStatus(" HIT "); got != "HIT" {
|
||||
t.Fatalf("normalizeCacheStatus hit = %q, want HIT", got)
|
||||
}
|
||||
if got := normalizeCacheStatus(""); got != "" {
|
||||
t.Fatalf("normalizeCacheStatus empty = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAccessLogsResetsOffsetAfterTruncate(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
logPath := filepath.Join(tempDir, "openflare_access.log")
|
||||
if err := os.WriteFile(logPath, []byte("{\"ts\":\"2026-03-14T09:00:00Z\",\"host\":\"app.example.com\",\"path\":\"/\",\"remote_addr\":\"10.0.0.3\",\"status\":200,\"bytes_sent\":1}\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
|
||||
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
|
||||
if err := stateStore.Save(&state.Snapshot{AccessLogOffset: 4096}); err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
|
||||
accessLogs := CollectAccessLogs(&config.Config{AccessLogPath: logPath}, stateStore)
|
||||
if len(accessLogs) != 1 {
|
||||
t.Fatalf("expected one access log after truncate reset, got %+v", accessLogs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAccessLogsTruncatesLongAccessLogPath(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
logPath := filepath.Join(tempDir, "openflare_access.log")
|
||||
longPath := "/" + strings.Repeat("a", 140)
|
||||
content := []byte(
|
||||
"{\"ts\":\"2026-03-14T08:00:00Z\",\"host\":\"app.example.com\",\"path\":\"" + longPath + "\",\"remote_addr\":\"10.0.0.1\",\"status\":200}\n",
|
||||
)
|
||||
if err := os.WriteFile(logPath, content, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
|
||||
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
|
||||
accessLogs := CollectAccessLogs(&config.Config{AccessLogPath: logPath}, stateStore)
|
||||
if len(accessLogs) != 1 {
|
||||
t.Fatalf("expected one access log, got %+v", accessLogs)
|
||||
}
|
||||
if got := len([]rune(accessLogs[0].Path)); got != accessLogPathMaxRunes {
|
||||
t.Fatalf("expected truncated path length %d, got %d (%q)", accessLogPathMaxRunes, got, accessLogs[0].Path)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user