diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 938a5e27..466e5df6 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -24,6 +24,7 @@ sidebar: false ### 变更 - 访问日志页面重构为「概览」与「日志明细」两个标签页:概览展示请求量、访问量、带宽趋势与 Top Paths/Hosts/IPs,明细列表保留检索能力;已移除时间折叠与 IP 汇总视图。 +- 边缘访问日志现支持上报并存储 User-Agent;概览新增设备类型饼图,以及浏览器、操作系统、User-Agent 排行。 ## [v3.4.0] - 2026-07-18 diff --git a/docs/docs.go b/docs/docs.go index ea3eced7..158cec27 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -14325,6 +14325,9 @@ const docTemplate = `{ }, "status_code": { "type": "integer" + }, + "user_agent": { + "type": "string" } } }, @@ -16232,15 +16235,33 @@ const docTemplate = `{ "observability.AccessLogOverview": { "type": "object", "properties": { + "device_types": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, "generated_at": { "type": "string" }, "hours": { "type": "integer" }, + "status_codes": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, "summary": { "$ref": "#/definitions/observability.AccessLogOverviewSummary" }, + "top_browsers": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, "top_hosts": { "type": "array", "items": { @@ -16253,12 +16274,24 @@ const docTemplate = `{ "$ref": "#/definitions/observability.DistributionItem" } }, + "top_operating_systems": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, "top_paths": { "type": "array", "items": { "$ref": "#/definitions/observability.DistributionItem" } }, + "top_user_agents": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, "trends": { "$ref": "#/definitions/observability.AccessLogOverviewTrends" } @@ -16341,6 +16374,9 @@ const docTemplate = `{ }, "status_code": { "type": "integer" + }, + "user_agent": { + "type": "string" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 5e1813d6..8ddda97a 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -14318,6 +14318,9 @@ }, "status_code": { "type": "integer" + }, + "user_agent": { + "type": "string" } } }, @@ -16225,15 +16228,33 @@ "observability.AccessLogOverview": { "type": "object", "properties": { + "device_types": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, "generated_at": { "type": "string" }, "hours": { "type": "integer" }, + "status_codes": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, "summary": { "$ref": "#/definitions/observability.AccessLogOverviewSummary" }, + "top_browsers": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, "top_hosts": { "type": "array", "items": { @@ -16246,12 +16267,24 @@ "$ref": "#/definitions/observability.DistributionItem" } }, + "top_operating_systems": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, "top_paths": { "type": "array", "items": { "$ref": "#/definitions/observability.DistributionItem" } }, + "top_user_agents": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, "trends": { "$ref": "#/definitions/observability.AccessLogOverviewTrends" } @@ -16334,6 +16367,9 @@ }, "status_code": { "type": "integer" + }, + "user_agent": { + "type": "string" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 63b596dd..a7db611e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -673,6 +673,8 @@ definitions: type: integer status_code: type: integer + user_agent: + type: string type: object github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth: properties: @@ -1939,12 +1941,24 @@ definitions: type: object observability.AccessLogOverview: properties: + device_types: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array generated_at: type: string hours: type: integer + status_codes: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array summary: $ref: '#/definitions/observability.AccessLogOverviewSummary' + top_browsers: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array top_hosts: items: $ref: '#/definitions/observability.DistributionItem' @@ -1953,10 +1967,18 @@ definitions: items: $ref: '#/definitions/observability.DistributionItem' type: array + top_operating_systems: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array top_paths: items: $ref: '#/definitions/observability.DistributionItem' type: array + top_user_agents: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array trends: $ref: '#/definitions/observability.AccessLogOverviewTrends' type: object @@ -2011,6 +2033,8 @@ definitions: type: string status_code: type: integer + user_agent: + type: string type: object observability.CapacityTrendPoint: properties: diff --git a/frontend/app/(main)/access-logs/components/detail-tab.tsx b/frontend/app/(main)/access-logs/components/detail-tab.tsx index 30ddfceb..3eb6c741 100644 --- a/frontend/app/(main)/access-logs/components/detail-tab.tsx +++ b/frontend/app/(main)/access-logs/components/detail-tab.tsx @@ -123,6 +123,7 @@ export function DetailTab({ IP 域名 路径 + User-Agent 状态码 @@ -139,9 +140,15 @@ export function DetailTab({ {item.remote_addr} {item.host} - + {item.path} + + {item.user_agent || '—'} + {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;