{item.status_code}
diff --git a/frontend/app/(main)/access-logs/components/overview-tab.tsx b/frontend/app/(main)/access-logs/components/overview-tab.tsx
index 5c3bd202..5fd8b510 100644
--- a/frontend/app/(main)/access-logs/components/overview-tab.tsx
+++ b/frontend/app/(main)/access-logs/components/overview-tab.tsx
@@ -3,6 +3,7 @@
import { useMemo } from 'react';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
+import { Cell, Pie, PieChart } from 'recharts';
import { RankChart } from '@/components/data/rank-chart';
import { TrendChart } from '@/components/data/trend-chart';
@@ -16,8 +17,19 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
+import {
+ ChartContainer,
+ ChartLegend,
+ ChartLegendContent,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from '@/components/ui/chart';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
-import type { AccessLogOverview } from '@/lib/services/openflare';
+import type {
+ AccessLogOverview,
+ DistributionItem,
+} from '@/lib/services/openflare';
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
import {
@@ -27,6 +39,15 @@ import {
type OverviewRangeHours,
} from './access-log-utils';
+const DEVICE_COLORS = [
+ '#38bdf8',
+ '#34d399',
+ '#f59e0b',
+ '#a78bfa',
+ '#f472b6',
+ '#94a3b8',
+];
+
function SparklineMetricCard({
title,
value,
@@ -132,6 +153,13 @@ function SparklineMetricCard({
);
}
+function toRankItems(items: DistributionItem[] | undefined) {
+ return (items ?? []).map((item) => ({
+ label: item.key,
+ value: item.value,
+ }));
+}
+
function RankCard({
title,
description,
@@ -164,6 +192,101 @@ function RankCard({
);
}
+function PieDistributionCard({
+ title,
+ description,
+ items,
+ emptyMessage,
+}: {
+ title: string;
+ description: string;
+ items: DistributionItem[];
+ emptyMessage: string;
+}) {
+ const chartData = useMemo(
+ () =>
+ items.map((item, index) => ({
+ name: item.key,
+ value: item.value,
+ fill: DEVICE_COLORS[index % DEVICE_COLORS.length],
+ })),
+ [items],
+ );
+
+ const chartConfig = useMemo(() => {
+ const config: ChartConfig = {};
+ chartData.forEach((item) => {
+ config[item.name] = {
+ label: item.name,
+ color: item.fill,
+ };
+ });
+ return config;
+ }, [chartData]);
+
+ return (
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+ {chartData.length === 0 ? (
+
+ {emptyMessage}
+
+ ) : (
+
+
+
+ {chartData.map((entry) => (
+ |
+ ))}
+
+ (
+ <>
+ {name}
+
+ {formatCompactNumber(Number(value ?? 0))}
+
+ >
+ )}
+ />
+ }
+ />
+ }
+ className='flex-wrap justify-center gap-x-4 gap-y-1 pt-2 text-[11px]'
+ />
+
+
+ )}
+
+
+ );
+}
+
function OverviewRangeSwitch({
hours,
onHoursChange,
@@ -314,33 +437,60 @@ function OverviewContent({
+
+
({
- label: item.key,
- value: item.value,
- }))}
+ items={toRankItems(data.top_paths)}
/>
({
- label: item.key,
- value: item.value,
- }))}
+ items={toRankItems(data.top_hosts)}
/>
({
- label: item.key,
- value: item.value,
- }))}
+ items={toRankItems(data.top_ips)}
+ />
+
+
+
+
+
+
>
diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts
index 7bc1c1fd..ebcd529b 100644
--- a/frontend/lib/services/openflare/types.ts
+++ b/frontend/lib/services/openflare/types.ts
@@ -527,6 +527,11 @@ export interface AccessLogOverview {
top_paths: DistributionItem[];
top_hosts: DistributionItem[];
top_ips: DistributionItem[];
+ device_types: DistributionItem[];
+ top_browsers: DistributionItem[];
+ top_operating_systems: DistributionItem[];
+ top_user_agents: DistributionItem[];
+ status_codes: DistributionItem[];
}
export interface AccessLogItem {
@@ -538,6 +543,7 @@ export interface AccessLogItem {
region: string;
host: string;
path: string;
+ user_agent: string;
status_code: number;
}
diff --git a/internal/apps/agent/observability/traffic.go b/internal/apps/agent/observability/traffic.go
index 479d346f..1ebd31a4 100644
--- a/internal/apps/agent/observability/traffic.go
+++ b/internal/apps/agent/observability/traffic.go
@@ -22,6 +22,7 @@ type accessLogRecord struct {
Host string `json:"host"`
RemoteAddr string `json:"remote_addr"`
Path string `json:"path"`
+ UserAgent string `json:"user_agent"`
Status int `json:"status"`
BytesSent int64 `json:"bytes_sent"`
RequestLength int64 `json:"request_length"`
@@ -145,6 +146,7 @@ func (aggregate *trafficAggregate) consume(line []byte) {
RemoteAddr: strings.TrimSpace(record.RemoteAddr),
Host: strings.TrimSpace(record.Host),
Path: normalizeAccessLogPath(record.Path),
+ UserAgent: strings.TrimSpace(record.UserAgent),
StatusCode: record.Status,
BytesSent: record.BytesSent,
RequestLength: record.RequestLength,
@@ -157,6 +159,7 @@ type parsedAccessLogRecord struct {
Host string
RemoteAddr string
Path string
+ UserAgent string
Status int
BytesSent int64
RequestLength int64
@@ -189,6 +192,7 @@ func parseJSONAccessLogRecord(raw string) (parsedAccessLogRecord, bool) {
Host: strings.TrimSpace(record.Host),
RemoteAddr: strings.TrimSpace(record.RemoteAddr),
Path: normalizeAccessLogPath(record.Path),
+ UserAgent: strings.TrimSpace(record.UserAgent),
Status: record.Status,
BytesSent: record.BytesSent,
RequestLength: record.RequestLength,
diff --git a/internal/apps/agent/observability/traffic_test.go b/internal/apps/agent/observability/traffic_test.go
index 59e05d62..b7cc9696 100644
--- a/internal/apps/agent/observability/traffic_test.go
+++ b/internal/apps/agent/observability/traffic_test.go
@@ -14,8 +14,8 @@ 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}\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}\n",
+ "{\"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\"}\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\"}\n",
)
if err := os.WriteFile(logPath, content, 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
@@ -35,6 +35,9 @@ func TestCollectAccessLogsReturnsFactsOnly(t *testing.T) {
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)
+ }
snapshot, err := stateStore.Load()
if err != nil {
diff --git a/internal/apps/agent/state/observability_buffer.go b/internal/apps/agent/state/observability_buffer.go
index 8e2fa5e5..4132018d 100644
--- a/internal/apps/agent/state/observability_buffer.go
+++ b/internal/apps/agent/state/observability_buffer.go
@@ -117,7 +117,7 @@ func mergeAccessLogs(existing []protocol.NodeAccessLog, incoming []protocol.Node
}
func accessLogKey(item protocol.NodeAccessLog) string {
- return strconv.FormatInt(item.LoggedAtUnix, 10) + "|" + item.RemoteAddr + "|" + item.Host + "|" + item.Path + "|" + strconv.Itoa(item.StatusCode)
+ return strconv.FormatInt(item.LoggedAtUnix, 10) + "|" + item.RemoteAddr + "|" + item.Host + "|" + item.Path + "|" + item.UserAgent + "|" + strconv.Itoa(item.StatusCode)
}
// Replayable returns buffered records from windows before currentWindowStartedAtUnix.
diff --git a/internal/apps/openflare/agent/observability.go b/internal/apps/openflare/agent/observability.go
index 65f8341b..c2ca984a 100644
--- a/internal/apps/openflare/agent/observability.go
+++ b/internal/apps/openflare/agent/observability.go
@@ -24,6 +24,7 @@ const (
healthSeverityWarning = "warning"
healthSeverityCritical = "critical"
accessLogPathMaxLength = 100
+ accessLogUserAgentMaxLength = 512
healthEventMessageMaxLength = 4096
)
@@ -208,6 +209,7 @@ func buildNodeAccessLogRecords(nodeID string, direct []NodeAccessLog, buffered [
Region: "",
Host: strings.TrimSpace(item.Host),
Path: truncateForDatabase(strings.TrimSpace(item.Path), accessLogPathMaxLength),
+ UserAgent: truncateForDatabase(strings.TrimSpace(item.UserAgent), accessLogUserAgentMaxLength),
StatusCode: item.StatusCode,
BytesSent: bytesSent,
RequestLength: requestLength,
diff --git a/internal/apps/openflare/observability/access_log_logics.go b/internal/apps/openflare/observability/access_log_logics.go
index ca0416f7..634d3c4c 100644
--- a/internal/apps/openflare/observability/access_log_logics.go
+++ b/internal/apps/openflare/observability/access_log_logics.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/Rain-kl/Wavelet/internal/model"
+ analyticsrepo "github.com/Rain-kl/Wavelet/internal/repository/analytics"
)
const (
@@ -52,6 +53,7 @@ type AccessLogView struct {
Region string `json:"region"`
Host string `json:"host"`
Path string `json:"path"`
+ UserAgent string `json:"user_agent"`
StatusCode int `json:"status_code"`
}
@@ -207,13 +209,18 @@ type AccessLogOverviewTrends struct {
// AccessLogOverview is the access-log analytics overview payload.
type AccessLogOverview struct {
- GeneratedAt time.Time `json:"generated_at"`
- Hours int `json:"hours"`
- Summary AccessLogOverviewSummary `json:"summary"`
- Trends AccessLogOverviewTrends `json:"trends"`
- TopPaths []DistributionItem `json:"top_paths"`
- TopHosts []DistributionItem `json:"top_hosts"`
- TopIPs []DistributionItem `json:"top_ips"`
+ GeneratedAt time.Time `json:"generated_at"`
+ Hours int `json:"hours"`
+ Summary AccessLogOverviewSummary `json:"summary"`
+ Trends AccessLogOverviewTrends `json:"trends"`
+ TopPaths []DistributionItem `json:"top_paths"`
+ TopHosts []DistributionItem `json:"top_hosts"`
+ TopIPs []DistributionItem `json:"top_ips"`
+ DeviceTypes []DistributionItem `json:"device_types"`
+ TopBrowsers []DistributionItem `json:"top_browsers"`
+ TopOperatingSystems []DistributionItem `json:"top_operating_systems"`
+ TopUserAgents []DistributionItem `json:"top_user_agents"`
+ StatusCodes []DistributionItem `json:"status_codes"`
}
// AccessLogCleanupInput is the cleanup request payload.
@@ -229,9 +236,10 @@ type AccessLogCleanupResult struct {
}
const (
- defaultAccessLogOverviewHours = 24
- maxAccessLogOverviewHours = 24 * 30
- accessLogOverviewTopLimit = 10
+ defaultAccessLogOverviewHours = 24
+ maxAccessLogOverviewHours = 24 * 30
+ accessLogOverviewTopLimit = 10
+ accessLogOverviewUASampleLimit = 200
)
// GetAccessLogOverview returns summary metrics, trends, and top rankings.
@@ -254,6 +262,7 @@ func GetAccessLogOverview(ctx context.Context, input AccessLogOverviewQuery) (*A
requestPoints, visitPoints, bandwidthPoints := buildAccessLogOverviewTrends(
ctx, now, normalized.Hours, query,
)
+ deviceTypes, topBrowsers, topOSes, topUserAgents := buildAccessLogUADistributions(ctx, query)
return &AccessLogOverview{
GeneratedAt: now,
@@ -268,9 +277,14 @@ func GetAccessLogOverview(ctx context.Context, input AccessLogOverviewQuery) (*A
Visits: visitPoints,
Bandwidth: bandwidthPoints,
},
- TopPaths: valueCountDistribution(ctx, query, "path", accessLogOverviewTopLimit),
- TopHosts: valueCountDistribution(ctx, query, "host", accessLogOverviewTopLimit),
- TopIPs: valueCountDistribution(ctx, query, "remote_addr", accessLogOverviewTopLimit),
+ TopPaths: valueCountDistribution(ctx, query, "path", accessLogOverviewTopLimit),
+ TopHosts: valueCountDistribution(ctx, query, "host", accessLogOverviewTopLimit),
+ TopIPs: valueCountDistribution(ctx, query, "remote_addr", accessLogOverviewTopLimit),
+ DeviceTypes: deviceTypes,
+ TopBrowsers: topBrowsers,
+ TopOperatingSystems: topOSes,
+ TopUserAgents: topUserAgents,
+ StatusCodes: valueCountDistribution(ctx, query, "status_code", accessLogOverviewTopLimit),
}, nil
}
@@ -309,6 +323,45 @@ func valueCountDistribution(
return items
}
+func buildAccessLogUADistributions(
+ ctx context.Context,
+ query model.OpenFlareAccessLogQuery,
+) (
+ deviceTypes []DistributionItem,
+ topBrowsers []DistributionItem,
+ topOSes []DistributionItem,
+ topUserAgents []DistributionItem,
+) {
+ uaRows := valueCountDistribution(ctx, query, "user_agent", accessLogOverviewUASampleLimit)
+ if len(uaRows) == 0 {
+ return []DistributionItem{}, []DistributionItem{}, []DistributionItem{}, []DistributionItem{}
+ }
+
+ deviceAcc := make(distributionAccumulator)
+ browserAcc := make(distributionAccumulator)
+ osAcc := make(distributionAccumulator)
+ for _, row := range uaRows {
+ ua := row.Key
+ count := row.Value
+ deviceAcc[analyticsrepo.ParseDeviceType(ua)] += count
+ browserAcc[analyticsrepo.ParseBrowserName(ua)] += count
+ osAcc[analyticsrepo.ParseOSName(ua)] += count
+ }
+
+ topUserAgents = make([]DistributionItem, 0, accessLogOverviewTopLimit)
+ for _, row := range uaRows {
+ if len(topUserAgents) >= accessLogOverviewTopLimit {
+ break
+ }
+ topUserAgents = append(topUserAgents, row)
+ }
+
+ return toDistributionItems(deviceAcc, 0),
+ toDistributionItems(browserAcc, accessLogOverviewTopLimit),
+ toDistributionItems(osAcc, accessLogOverviewTopLimit),
+ topUserAgents
+}
+
func buildAccessLogOverviewTrends(
ctx context.Context,
now time.Time,
@@ -394,6 +447,7 @@ func ListAccessLogs(ctx context.Context, input AccessLogQuery) (*AccessLogList,
Region: item.Region,
Host: item.Host,
Path: item.Path,
+ UserAgent: item.UserAgent,
StatusCode: item.StatusCode,
})
}
diff --git a/internal/db/migrator/goose/clickhouse/202607180004_access_log_user_agent.sql b/internal/db/migrator/goose/clickhouse/202607180004_access_log_user_agent.sql
new file mode 100644
index 00000000..2ed470a4
--- /dev/null
+++ b/internal/db/migrator/goose/clickhouse/202607180004_access_log_user_agent.sql
@@ -0,0 +1,6 @@
+-- +goose Up
+ALTER TABLE of_node_access_logs
+ ADD COLUMN IF NOT EXISTS user_agent String DEFAULT '';
+
+-- +goose Down
+ALTER TABLE of_node_access_logs DROP COLUMN IF EXISTS user_agent;
diff --git a/internal/model/analytics/node_access_log.go b/internal/model/analytics/node_access_log.go
index 1c819e4f..2ab62e01 100644
--- a/internal/model/analytics/node_access_log.go
+++ b/internal/model/analytics/node_access_log.go
@@ -10,7 +10,7 @@ import (
const (
nodeAccessLogTableName = "of_node_access_logs"
- nodeAccessLogInsertColumns = "id, node_id, logged_at, remote_addr, region, host, path, status_code, bytes_sent, request_length, request_time_ms, created_at"
+ nodeAccessLogInsertColumns = "id, node_id, logged_at, remote_addr, region, host, path, user_agent, status_code, bytes_sent, request_length, request_time_ms, created_at"
)
// NodeAccessLog stores OpenFlare edge node access records in ClickHouse.
@@ -22,6 +22,7 @@ type NodeAccessLog struct {
Region string `gorm:"column:region"`
Host string `gorm:"column:host"`
Path string `gorm:"column:path"`
+ UserAgent string `gorm:"column:user_agent"`
StatusCode int32 `gorm:"column:status_code"`
BytesSent uint64 `gorm:"column:bytes_sent"`
RequestLength uint64 `gorm:"column:request_length"`
diff --git a/internal/model/openflare_access_log.go b/internal/model/openflare_access_log.go
index 764aebc3..9ac1ab96 100644
--- a/internal/model/openflare_access_log.go
+++ b/internal/model/openflare_access_log.go
@@ -77,7 +77,7 @@ func TrafficSummaryOpenFlareAccessLogs(ctx context.Context, query OpenFlareAcces
return currentAccessLogStore().TrafficSummary(ctx, query)
}
-// ValueCountsOpenFlareAccessLogs groups logs by status_code, host, path, or remote_addr.
+// ValueCountsOpenFlareAccessLogs groups logs by status_code, host, path, remote_addr, or user_agent.
func ValueCountsOpenFlareAccessLogs(ctx context.Context, query OpenFlareAccessLogQuery, column string, limit int) ([]OpenFlareAccessLogValueCount, error) {
return currentAccessLogStore().ValueCounts(ctx, query, column, limit)
}
diff --git a/internal/model/openflare_access_log_store.go b/internal/model/openflare_access_log_store.go
index 90c5a36a..dced6bca 100644
--- a/internal/model/openflare_access_log_store.go
+++ b/internal/model/openflare_access_log_store.go
@@ -284,6 +284,7 @@ func toAnalyticsNodeAccessLog(record *OpenFlareAccessLog) analyticsmodel.NodeAcc
Region: record.Region,
Host: record.Host,
Path: record.Path,
+ UserAgent: record.UserAgent,
StatusCode: openFlareAccessLogStatusCodeToInt32(record.StatusCode),
BytesSent: bytesSent,
RequestLength: requestLength,
@@ -315,6 +316,7 @@ func fromAnalyticsNodeAccessLogs(rows []analyticsmodel.NodeAccessLog) []*OpenFla
Region: row.Region,
Host: row.Host,
Path: row.Path,
+ UserAgent: row.UserAgent,
StatusCode: int(row.StatusCode),
BytesSent: bytesSent,
RequestLength: requestLength,
diff --git a/internal/model/openflare_access_log_store_memory.go b/internal/model/openflare_access_log_store_memory.go
index c86001ec..5af2de57 100644
--- a/internal/model/openflare_access_log_store_memory.go
+++ b/internal/model/openflare_access_log_store_memory.go
@@ -22,6 +22,7 @@ const (
accessLogColumnHost = "host"
accessLogColumnPath = "path"
accessLogColumnRemoteAddr = "remote_addr"
+ accessLogColumnUserAgent = "user_agent"
)
type memoryAccessLogStore struct {
@@ -476,7 +477,7 @@ func (s *memoryAccessLogStore) ValueCounts(_ context.Context, filter OpenFlareAc
defer s.mu.RUnlock()
col := strings.TrimSpace(strings.ToLower(column))
switch col {
- case accessLogColumnStatusCode, accessLogColumnHost, accessLogColumnPath, accessLogColumnRemoteAddr:
+ case accessLogColumnStatusCode, accessLogColumnHost, accessLogColumnPath, accessLogColumnRemoteAddr, accessLogColumnUserAgent:
default:
return nil, nil
}
@@ -493,6 +494,8 @@ func (s *memoryAccessLogStore) ValueCounts(_ context.Context, filter OpenFlareAc
value = strings.TrimSpace(row.Path)
case accessLogColumnRemoteAddr:
value = strings.TrimSpace(row.RemoteAddr)
+ case accessLogColumnUserAgent:
+ value = strings.TrimSpace(row.UserAgent)
}
if value == "" {
continue
diff --git a/internal/model/openflare_observability.go b/internal/model/openflare_observability.go
index d631be76..dd07ba73 100644
--- a/internal/model/openflare_observability.go
+++ b/internal/model/openflare_observability.go
@@ -47,6 +47,7 @@ type OpenFlareAccessLog struct {
Region string `json:"region" gorm:"size:128"`
Host string `json:"host" gorm:"index;size:255"`
Path string `json:"path" gorm:"size:2048"`
+ UserAgent string `json:"user_agent" gorm:"column:user_agent;size:512"`
StatusCode int `json:"status_code" gorm:"index"`
BytesSent int64 `json:"bytes_sent" gorm:"column:bytes_sent;not null;default:0"`
RequestLength int64 `json:"request_length" gorm:"column:request_length;not null;default:0"`
diff --git a/internal/model/openflare_option.go b/internal/model/openflare_option.go
index 57a8449e..48d69157 100644
--- a/internal/model/openflare_option.go
+++ b/internal/model/openflare_option.go
@@ -38,7 +38,7 @@ http {
fastcgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/fastcgi_temp;
uwsgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/uwsgi_temp;
scgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/scgi_temp;
-{{OpenRestyConnectionUpgradeMap}}{{OpenRestyDefaultServerBlock}} log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}';
+{{OpenRestyConnectionUpgradeMap}}{{OpenRestyDefaultServerBlock}} log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length,"user_agent":"$http_user_agent"}';
access_log {{OpenRestyAccessLogPath}} openflare_json;
sendfile on;
tcp_nopush on;
diff --git a/internal/repository/analytics/access_log_test.go b/internal/repository/analytics/access_log_test.go
index 67604915..3e6b67c1 100644
--- a/internal/repository/analytics/access_log_test.go
+++ b/internal/repository/analytics/access_log_test.go
@@ -28,7 +28,8 @@ func TestParseBrowserName(t *testing.T) {
{name: "edge", ua: "Mozilla/5.0 Edg/120.0.0.0", want: "Edge"},
{name: "wechat", ua: "MicroMessenger/8.0", want: "WeChat"},
{name: "postman", ua: "PostmanRuntime/7.36.0", want: "Postman"},
- {name: "other", ua: "curl/8.0", want: "Other"},
+ {name: "cli", ua: "curl/8.0", want: "CLI"},
+ {name: "other", ua: "CustomClient/1.0", want: "Other"},
}
for _, tt := range tests {
diff --git a/internal/repository/analytics/browser.go b/internal/repository/analytics/browser.go
index 8ff97f7f..ab956be1 100644
--- a/internal/repository/analytics/browser.go
+++ b/internal/repository/analytics/browser.go
@@ -5,26 +5,116 @@ package analytics
import "strings"
+const (
+ uaLabelUnknown = "Unknown"
+ uaLabelBot = "Bot"
+ uaLabelOther = "Other"
+ uaTokenBot = "bot"
+ uaTokenAndroid = "android"
+ uaTokenSpider = "spider"
+ uaTokenCrawler = "crawler"
+)
+
+type uaMatchRule struct {
+ label string
+ contains []string
+ allOf []string
+ noneOf []string
+}
+
+func matchUARules(uaLower string, rules []uaMatchRule, fallback string) string {
+ if uaLower == "" {
+ return uaLabelUnknown
+ }
+ for _, rule := range rules {
+ matched := false
+ for _, token := range rule.contains {
+ if strings.Contains(uaLower, token) {
+ matched = true
+ break
+ }
+ }
+ if !matched && len(rule.allOf) > 0 {
+ matched = true
+ for _, token := range rule.allOf {
+ if !strings.Contains(uaLower, token) {
+ matched = false
+ break
+ }
+ }
+ }
+ if !matched {
+ continue
+ }
+ excluded := false
+ for _, token := range rule.noneOf {
+ if strings.Contains(uaLower, token) {
+ excluded = true
+ break
+ }
+ }
+ if excluded {
+ continue
+ }
+ return rule.label
+ }
+ return fallback
+}
+
+var browserRules = []uaMatchRule{
+ {label: "WeChat", contains: []string{"micromessenger"}},
+ {label: "Postman", contains: []string{"postman"}},
+ {label: "CLI", contains: []string{"curl/", "wget/"}},
+ {label: "Edge", contains: []string{"edg/", "edgios/", "edga/"}},
+ {label: "Opera", contains: []string{"opr/", "opera"}},
+ {label: "Firefox", contains: []string{"firefox", "fxios"}},
+ {label: "Chrome", contains: []string{"crios", "chrome"}, noneOf: []string{"chromium"}},
+ {label: "Chromium", contains: []string{"chromium"}},
+ {label: "Safari", contains: []string{"safari"}},
+ {label: uaLabelBot, contains: []string{uaTokenBot, uaTokenSpider, uaTokenCrawler, "slurp"}},
+}
+
+var osRules = []uaMatchRule{
+ {label: "Android", contains: []string{uaTokenAndroid}},
+ {label: "iOS", contains: []string{"iphone", "ipad", "ipod", "ios"}},
+ {label: "Windows", contains: []string{"windows"}},
+ {label: "macOS", contains: []string{"mac os x", "macintosh", "macos"}},
+ {label: "Chrome OS", contains: []string{"cros"}},
+ {label: "Linux", contains: []string{"linux"}},
+ {label: uaLabelBot, contains: []string{uaTokenBot, uaTokenSpider, uaTokenCrawler}},
+}
+
+var deviceRules = []uaMatchRule{
+ {
+ label: uaLabelBot,
+ contains: []string{uaTokenBot, uaTokenSpider, uaTokenCrawler, "slurp", "curl/", "wget/", "python-requests", "go-http-client", "postman"},
+ },
+ {
+ label: "Tablet",
+ contains: []string{"ipad", "tablet"},
+ },
+ {
+ label: "Tablet",
+ allOf: []string{uaTokenAndroid},
+ noneOf: []string{"mobile"},
+ },
+ {
+ label: "Mobile",
+ contains: []string{"mobi", "iphone", "ipod", uaTokenAndroid},
+ },
+}
+
// ParseBrowserName performs lightweight User-Agent browser identification.
func ParseBrowserName(ua string) string {
- uaLower := strings.ToLower(ua)
- if strings.Contains(uaLower, "micromessenger") {
- return "WeChat"
- }
- if strings.Contains(uaLower, "postman") {
- return "Postman"
- }
- if strings.Contains(uaLower, "edg/") || strings.Contains(uaLower, "edge") {
- return "Edge"
- }
- if strings.Contains(uaLower, "firefox") {
- return "Firefox"
- }
- if strings.Contains(uaLower, "chrome") {
- return "Chrome"
- }
- if strings.Contains(uaLower, "safari") {
- return "Safari"
- }
- return "Other"
+ return matchUARules(strings.ToLower(ua), browserRules, uaLabelOther)
+}
+
+// ParseOSName performs lightweight User-Agent OS identification.
+func ParseOSName(ua string) string {
+ return matchUARules(strings.ToLower(ua), osRules, uaLabelOther)
+}
+
+// ParseDeviceType performs lightweight User-Agent device type identification.
+func ParseDeviceType(ua string) string {
+ return matchUARules(strings.ToLower(ua), deviceRules, "Desktop")
}
diff --git a/internal/repository/analytics/node_access_log.go b/internal/repository/analytics/node_access_log.go
index 2369f6a4..d0a8a661 100644
--- a/internal/repository/analytics/node_access_log.go
+++ b/internal/repository/analytics/node_access_log.go
@@ -36,7 +36,7 @@ func ListNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter) ([]anal
clause, args := buildNodeAccessLogFilterClause(filter)
tableName := nodeAccessLogTableName()
sql := fmt.Sprintf(`
-SELECT id, node_id, logged_at, remote_addr, region, host, path, status_code, bytes_sent, request_length, request_time_ms, created_at
+SELECT id, node_id, logged_at, remote_addr, region, host, path, user_agent, 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))
@@ -55,6 +55,7 @@ ORDER BY %s`, tableName, clause, nodeAccessLogOrderClause(filter.SortBy, filter.
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() {
@@ -67,6 +68,7 @@ func scanNodeAccessLogRows(rows driver.Rows) ([]analyticsmodel.NodeAccessLog, er
&item.Region,
&item.Host,
&item.Path,
+ &item.UserAgent,
&item.StatusCode,
&item.BytesSent,
&item.RequestLength,
@@ -206,7 +208,7 @@ WHERE %s`, tableName, clause)
}
// ValueCountsNodeAccessLogs groups logs by a single dimension column.
-// Allowed columns: status_code, host, path, remote_addr.
+// 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 {
@@ -261,6 +263,8 @@ func nodeAccessLogValueCountExpr(column string) (string, bool) {
return "trim(" + nodeAccessLogColumnPath + ")", true
case nodeAccessLogColumnRemoteAddr:
return "trim(" + nodeAccessLogColumnRemoteAddr + ")", true
+ case nodeAccessLogColumnUserAgent:
+ return "trim(" + nodeAccessLogColumnUserAgent + ")", true
default:
return "", false
}
diff --git a/internal/repository/analytics/node_access_log_filter.go b/internal/repository/analytics/node_access_log_filter.go
index 0f842037..173555b7 100644
--- a/internal/repository/analytics/node_access_log_filter.go
+++ b/internal/repository/analytics/node_access_log_filter.go
@@ -20,6 +20,7 @@ const (
nodeAccessLogColumnStatusCode = "status_code"
nodeAccessLogColumnHost = "host"
nodeAccessLogColumnPath = "path"
+ nodeAccessLogColumnUserAgent = "user_agent"
nodeAccessLogColumnLoggedAt = "logged_at"
)
diff --git a/internal/repository/analytics/node_access_log_test.go b/internal/repository/analytics/node_access_log_test.go
index e80bf5e1..ae3377e4 100644
--- a/internal/repository/analytics/node_access_log_test.go
+++ b/internal/repository/analytics/node_access_log_test.go
@@ -49,8 +49,9 @@ func TestBatchInsertNodeAccessLogs_UsesModelBatchSQL(t *testing.T) {
assert.True(t, mockBatch.sendCalled)
require.Len(t, mockBatch.rows, 1)
assert.Equal(t, "node-a", mockBatch.rows[0][1])
- require.Len(t, mockBatch.rows[0], 12)
- assert.Equal(t, uint64(2048), mockBatch.rows[0][8]) // bytes_sent
- assert.Equal(t, uint64(0), mockBatch.rows[0][9]) // request_length
- assert.Equal(t, uint32(0), mockBatch.rows[0][10]) // request_time_ms
+ require.Len(t, mockBatch.rows[0], 13)
+ assert.Equal(t, "", mockBatch.rows[0][7]) // user_agent
+ assert.Equal(t, uint64(2048), mockBatch.rows[0][9]) // bytes_sent
+ assert.Equal(t, uint64(0), mockBatch.rows[0][10]) // request_length
+ assert.Equal(t, uint32(0), mockBatch.rows[0][11]) // request_time_ms
}
diff --git a/internal/repository/analytics/node_access_log_writer.go b/internal/repository/analytics/node_access_log_writer.go
index 63e1048d..d69a167b 100644
--- a/internal/repository/analytics/node_access_log_writer.go
+++ b/internal/repository/analytics/node_access_log_writer.go
@@ -46,6 +46,7 @@ func BatchInsertNodeAccessLogs(ctx context.Context, logs []analyticsmodel.NodeAc
logItem.Region,
logItem.Host,
logItem.Path,
+ strings.TrimSpace(logItem.UserAgent),
logItem.StatusCode,
logItem.BytesSent,
logItem.RequestLength,
diff --git a/internal/repository/analytics/node_observability.go b/internal/repository/analytics/node_observability.go
index 401a6180..a6581dd7 100644
--- a/internal/repository/analytics/node_observability.go
+++ b/internal/repository/analytics/node_observability.go
@@ -159,6 +159,7 @@ ORDER BY %s`, tableName, clause, nodeObservabilityCapturedAtOrderClause())
return scanNodeObsFrpcRows(rows)
}
+//nolint:dupl // scan shapes differ by model fields; shared helper would obscure CH column mapping
func scanNodeMetricSnapshotRows(rows driver.Rows) ([]analyticsmodel.NodeMetricSnapshot, error) {
var result []analyticsmodel.NodeMetricSnapshot
for rows.Next() {
diff --git a/pkg/protocol/agent.go b/pkg/protocol/agent.go
index dc714e6f..80be3e6d 100644
--- a/pkg/protocol/agent.go
+++ b/pkg/protocol/agent.go
@@ -138,6 +138,7 @@ type NodeAccessLog struct {
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Path string `json:"path"`
+ UserAgent string `json:"user_agent,omitempty"`
StatusCode int `json:"status_code"`
BytesSent int64 `json:"bytes_sent"` // body bytes = 已提供数据
RequestLength int64 `json:"request_length"` // 接收数据
diff --git a/pkg/render/openresty/types.go b/pkg/render/openresty/types.go
index cb9256d5..1fe3c6f0 100644
--- a/pkg/render/openresty/types.go
+++ b/pkg/render/openresty/types.go
@@ -60,7 +60,7 @@ http {
fastcgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/fastcgi_temp;
uwsgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/uwsgi_temp;
scgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/scgi_temp;
-{{OpenRestyConnectionUpgradeMap}}{{OpenRestyDefaultServerBlock}} log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}';
+{{OpenRestyConnectionUpgradeMap}}{{OpenRestyDefaultServerBlock}} log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length,"user_agent":"$http_user_agent"}';
access_log {{OpenRestyAccessLogPath}} openflare_json;
sendfile on;
tcp_nopush on;