feat: enhance observability metrics collection and reporting

- Updated BuildSnapshot function to include OpenResty metrics.
- Enhanced BuildTrafficReport to utilize managed OpenResty metrics.
- Introduced new functions for parsing access logs in both JSON and combined formats.
- Added tests for traffic report generation from combined access logs.
- Implemented local observability metrics collection from OpenResty.
- Created Lua scripts for OpenResty to gather observability data.
- Updated configuration documentation to include new observability port and settings.
- Added support for OpenResty observability in the server configuration.
This commit is contained in:
ryan
2026-03-14 17:07:47 +08:00
parent 8cc839669c
commit bdfa80f214
19 changed files with 800 additions and 184 deletions
+2
View File
@@ -46,6 +46,7 @@ ATSFlare 当前定位为内部自用的反向代理控制面,不面向外部
* 配置版本化:支持预览、发布、激活、历史回滚,版本不可变 * 配置版本化:支持预览、发布、激活、历史回滚,版本不可变
* 节点接入:支持全局 `discovery_token` 首次接入,也支持节点专属 `agent_token` * 节点接入:支持全局 `discovery_token` 首次接入,也支持节点专属 `agent_token`
* Agent 自动应用:周期性同步、落盘、`openresty -t`、`openresty -s reload`、失败自动回滚 * Agent 自动应用:周期性同步、落盘、`openresty -t`、`openresty -s reload`、失败自动回滚
* 节点观测:Agent 会向受管 OpenResty 注入 Lua 观测脚本,按 heartbeat 上报最近窗口请求、错误、UV 与连接指标
* TLS 与域名管理:支持证书托管、域名资产维护、精确匹配与通配符匹配 * TLS 与域名管理:支持证书托管、域名资产维护、精确匹配与通配符匹配
* 运维能力:配置变更摘要、Agent 运行参数下发、Agent 正式版自动更新与 preview 手动升级、Server 正式版 GitHub 自升级、Server preview 手动检查升级、Server 手动上传二进制确认升级 * 运维能力:配置变更摘要、Agent 运行参数下发、Agent 正式版自动更新与 preview 手动升级、Server 正式版 GitHub 自升级、Server preview 手动检查升级、Server 手动上传二进制确认升级
* 管理端 UI:基于 Next.js App Router + React 19 + Tailwind CSS 4 的新版前端 * 管理端 UI:基于 Next.js App Router + React 19 + Tailwind CSS 4 的新版前端
@@ -213,6 +214,7 @@ Docker 镜像工作流仅构建 `atsf_server`,并产出 `linux/amd64` 与 `lin
| `agent_token` | 节点专属认证 Token | | `agent_token` | 节点专属认证 Token |
| `discovery_token` | 首次自动注册使用的全局 Token | | `discovery_token` | 首次自动注册使用的全局 Token |
| `data_dir` | Agent 托管数据目录 | | `data_dir` | Agent 托管数据目录 |
| `openresty_observability_port` | Agent 读取 OpenResty Lua 本地观测指标的 loopback 端口 |
| `nginx_path` | 本机 Nginx 路径,设置后走本机模式 | | `nginx_path` | 本机 Nginx 路径,设置后走本机模式 |
| `nginx_container_name` | Docker 模式下的 Nginx 容器名 | | `nginx_container_name` | Docker 模式下的 Nginx 容器名 |
+3
View File
@@ -41,6 +41,7 @@ func main() {
RouteConfigPath: cfg.RouteConfigPath, RouteConfigPath: cfg.RouteConfigPath,
CertDir: cfg.CertDir, CertDir: cfg.CertDir,
NginxCertDir: cfg.OpenrestyCertDir, NginxCertDir: cfg.OpenrestyCertDir,
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
}, },
) )
slog.Info("agent config loaded", slog.Info("agent config loaded",
@@ -64,6 +65,7 @@ func main() {
RuntimeRouteConfigPath: runtimeRouteConfigPath, RuntimeRouteConfigPath: runtimeRouteConfigPath,
CertDir: cfg.CertDir, CertDir: cfg.CertDir,
NginxCertDir: cfg.OpenrestyCertDir, NginxCertDir: cfg.OpenrestyCertDir,
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
Executor: nginx.NewExecutor(nginx.ExecutorOptions{ Executor: nginx.NewExecutor(nginx.ExecutorOptions{
NginxPath: cfg.OpenrestyPath, NginxPath: cfg.OpenrestyPath,
DockerBinary: cfg.DockerBinary, DockerBinary: cfg.DockerBinary,
@@ -73,6 +75,7 @@ func main() {
RouteConfigPath: cfg.RouteConfigPath, RouteConfigPath: cfg.RouteConfigPath,
CertDir: cfg.CertDir, CertDir: cfg.CertDir,
NginxCertDir: cfg.OpenrestyCertDir, NginxCertDir: cfg.OpenrestyCertDir,
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
}), }),
} }
runner := &agent.Runner{ runner := &agent.Runner{
+3 -2
View File
@@ -312,8 +312,9 @@ func (r *Runner) nodePayload(nodeID string) protocol.NodePayload {
openrestyStatus = protocol.OpenrestyStatusUnknown openrestyStatus = protocol.OpenrestyStatusUnknown
} }
profile := observability.BuildProfile(r.Config, r.StateStore) profile := observability.BuildProfile(r.Config, r.StateStore)
metricSnapshot := observability.BuildSnapshot(r.Config, r.StateStore) managedOpenRestyMetrics := observability.CollectManagedOpenRestyMetrics(r.Config)
trafficReport := observability.BuildTrafficReport(r.Config, r.StateStore) metricSnapshot := observability.BuildSnapshot(r.Config, r.StateStore, managedOpenRestyMetrics)
trafficReport := observability.BuildTrafficReport(r.Config, r.StateStore, managedOpenRestyMetrics)
healthEvents := observability.BuildHealthEvents(snapshot) healthEvents := observability.BuildHealthEvents(snapshot)
return protocol.NodePayload{ return protocol.NodePayload{
NodeID: nodeID, NodeID: nodeID,
+10
View File
@@ -17,6 +17,7 @@ const (
defaultCertDirRelativePath = "etc/nginx/certs" defaultCertDirRelativePath = "etc/nginx/certs"
defaultDockerStateRelativePath = "var/lib/atsflare/agent-state.json" defaultDockerStateRelativePath = "var/lib/atsflare/agent-state.json"
defaultDockerOpenRestyCertDir = "/etc/nginx/atsflare-certs" defaultDockerOpenRestyCertDir = "/etc/nginx/atsflare-certs"
defaultOpenRestyObservabilityPort = 18081
) )
type Config struct { type Config struct {
@@ -36,6 +37,7 @@ type Config struct {
RouteConfigPath string `json:"route_config_path"` RouteConfigPath string `json:"route_config_path"`
CertDir string `json:"cert_dir"` CertDir string `json:"cert_dir"`
OpenrestyCertDir string `json:"openresty_cert_dir"` OpenrestyCertDir string `json:"openresty_cert_dir"`
OpenrestyObservabilityPort int `json:"openresty_observability_port"`
StatePath string `json:"state_path"` StatePath string `json:"state_path"`
HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"` HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"`
RequestTimeout MillisecondDuration `json:"request_timeout"` RequestTimeout MillisecondDuration `json:"request_timeout"`
@@ -57,6 +59,7 @@ type configFile struct {
RouteConfigPath string `json:"route_config_path"` RouteConfigPath string `json:"route_config_path"`
CertDir string `json:"cert_dir"` CertDir string `json:"cert_dir"`
OpenrestyCertDir string `json:"openresty_cert_dir"` OpenrestyCertDir string `json:"openresty_cert_dir"`
OpenrestyObservabilityPort int `json:"openresty_observability_port"`
StatePath string `json:"state_path"` StatePath string `json:"state_path"`
HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"` HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"`
RequestTimeout MillisecondDuration `json:"request_timeout"` RequestTimeout MillisecondDuration `json:"request_timeout"`
@@ -86,6 +89,7 @@ func Load(path string) (*Config, error) {
RouteConfigPath: file.RouteConfigPath, RouteConfigPath: file.RouteConfigPath,
CertDir: file.CertDir, CertDir: file.CertDir,
OpenrestyCertDir: file.OpenrestyCertDir, OpenrestyCertDir: file.OpenrestyCertDir,
OpenrestyObservabilityPort: file.OpenrestyObservabilityPort,
StatePath: file.StatePath, StatePath: file.StatePath,
HeartbeatInterval: file.HeartbeatInterval, HeartbeatInterval: file.HeartbeatInterval,
RequestTimeout: file.RequestTimeout, RequestTimeout: file.RequestTimeout,
@@ -144,6 +148,9 @@ func applyDefaults(cfg *Config, baseDir string) {
cfg.OpenrestyCertDir = defaultDockerOpenRestyCertDir cfg.OpenrestyCertDir = defaultDockerOpenRestyCertDir
} }
} }
if cfg.OpenrestyObservabilityPort <= 0 {
cfg.OpenrestyObservabilityPort = defaultOpenRestyObservabilityPort
}
if cfg.HeartbeatInterval <= 0 { if cfg.HeartbeatInterval <= 0 {
cfg.HeartbeatInterval = MillisecondDuration(10 * time.Second) cfg.HeartbeatInterval = MillisecondDuration(10 * time.Second)
} }
@@ -198,6 +205,9 @@ func validate(cfg *Config) error {
if cfg.NodeIP == "" { if cfg.NodeIP == "" {
return errors.New("node_ip 不能为空") return errors.New("node_ip 不能为空")
} }
if cfg.OpenrestyObservabilityPort <= 0 || cfg.OpenrestyObservabilityPort > 65535 {
return errors.New("openresty_observability_port 必须在 1-65535 之间")
}
return nil return nil
} }
@@ -54,6 +54,9 @@ func TestLoadDockerModeUsesManagedPaths(t *testing.T) {
if cfg.StatePath != filepath.Join(dir, "data", defaultDockerStateRelativePath) { if cfg.StatePath != filepath.Join(dir, "data", defaultDockerStateRelativePath) {
t.Fatalf("unexpected state path: %s", cfg.StatePath) t.Fatalf("unexpected state path: %s", cfg.StatePath)
} }
if cfg.OpenrestyObservabilityPort != defaultOpenRestyObservabilityPort {
t.Fatalf("unexpected openresty observability port: %d", cfg.OpenrestyObservabilityPort)
}
} }
func TestLoadPathModeKeepsExplicitPaths(t *testing.T) { func TestLoadPathModeKeepsExplicitPaths(t *testing.T) {
@@ -94,6 +97,9 @@ func TestLoadPathModeKeepsExplicitPaths(t *testing.T) {
if cfg.OpenrestyCertDir != cfg.CertDir { if cfg.OpenrestyCertDir != cfg.CertDir {
t.Fatalf("expected path mode openresty cert dir to equal cert dir, got %s / %s", cfg.OpenrestyCertDir, cfg.CertDir) t.Fatalf("expected path mode openresty cert dir to equal cert dir, got %s / %s", cfg.OpenrestyCertDir, cfg.CertDir)
} }
if cfg.OpenrestyObservabilityPort != defaultOpenRestyObservabilityPort {
t.Fatalf("unexpected path mode openresty observability port: %d", cfg.OpenrestyObservabilityPort)
}
} }
func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) { func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
@@ -204,6 +210,9 @@ func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) {
if decoded["request_timeout"] != float64(7000) { if decoded["request_timeout"] != float64(7000) {
t.Fatalf("unexpected request timeout: %#v", decoded["request_timeout"]) t.Fatalf("unexpected request timeout: %#v", decoded["request_timeout"])
} }
if decoded["openresty_observability_port"] != float64(defaultOpenRestyObservabilityPort) {
t.Fatalf("unexpected observability port: %#v", decoded["openresty_observability_port"])
}
if _, ok := decoded["nginx_path"]; ok { if _, ok := decoded["nginx_path"]; ok {
t.Fatal("legacy nginx_path should not be persisted") t.Fatal("legacy nginx_path should not be persisted")
} }
+31 -1
View File
@@ -20,6 +20,8 @@ import (
const CertDirPlaceholder = "__ATSF_CERT_DIR__" const CertDirPlaceholder = "__ATSF_CERT_DIR__"
const RouteConfigPlaceholder = "__ATSF_ROUTE_CONFIG__" const RouteConfigPlaceholder = "__ATSF_ROUTE_CONFIG__"
const AccessLogPlaceholder = "__ATSF_ACCESS_LOG__" const AccessLogPlaceholder = "__ATSF_ACCESS_LOG__"
const LuaDirPlaceholder = "__ATSF_LUA_DIR__"
const ObservabilityPortPlaceholder = "__ATSF_OBSERVABILITY_PORT__"
const DockerMainConfigPath = "/usr/local/openresty/nginx/conf/nginx.conf" const DockerMainConfigPath = "/usr/local/openresty/nginx/conf/nginx.conf"
const DockerRouteConfigPath = "/etc/nginx/conf.d/atsflare_routes.conf" const DockerRouteConfigPath = "/etc/nginx/conf.d/atsflare_routes.conf"
const DockerAccessLogPath = "/etc/nginx/conf.d/atsflare_access.log" const DockerAccessLogPath = "/etc/nginx/conf.d/atsflare_access.log"
@@ -104,6 +106,7 @@ type DockerExecutor struct {
RouteConfigDir string RouteConfigDir string
CertDir string CertDir string
NginxCertDir string NginxCertDir string
OpenrestyObservabilityPort int
Runner CommandRunner Runner CommandRunner
} }
@@ -180,6 +183,7 @@ func (e *DockerExecutor) runContainer(ctx context.Context) error {
"--name", e.ContainerName, "--name", e.ContainerName,
"-p", "80:80", "-p", "80:80",
"-p", "443:443", "-p", "443:443",
"-p", fmt.Sprintf("127.0.0.1:%d:%d", e.OpenrestyObservabilityPort, e.OpenrestyObservabilityPort),
"-v", fmt.Sprintf("%s:%s", e.MainConfigPath, DockerMainConfigPath), "-v", fmt.Sprintf("%s:%s", e.MainConfigPath, DockerMainConfigPath),
"-v", fmt.Sprintf("%s:/etc/nginx/conf.d", e.RouteConfigDir), "-v", fmt.Sprintf("%s:/etc/nginx/conf.d", e.RouteConfigDir),
"-v", fmt.Sprintf("%s:%s", e.CertDir, e.NginxCertDir), "-v", fmt.Sprintf("%s:%s", e.CertDir, e.NginxCertDir),
@@ -199,6 +203,7 @@ type Manager struct {
RuntimeRouteConfigPath string RuntimeRouteConfigPath string
CertDir string CertDir string
NginxCertDir string NginxCertDir string
OpenrestyObservabilityPort int
Executor Executor Executor Executor
} }
@@ -290,6 +295,12 @@ func (m *Manager) CurrentChecksum() (string, error) {
if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" { if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" {
normalizedMain = strings.ReplaceAll(normalizedMain, accessLogPath, AccessLogPlaceholder) normalizedMain = strings.ReplaceAll(normalizedMain, accessLogPath, AccessLogPlaceholder)
} }
if luaDir := m.luaRuntimePath(); luaDir != "" {
normalizedMain = strings.ReplaceAll(normalizedMain, luaDir, LuaDirPlaceholder)
}
if m.OpenrestyObservabilityPort > 0 {
normalizedMain = strings.ReplaceAll(normalizedMain, fmt.Sprintf("%d", m.OpenrestyObservabilityPort), ObservabilityPortPlaceholder)
}
normalizedRoute := string(data) normalizedRoute := string(data)
if m.NginxCertDir != "" { if m.NginxCertDir != "" {
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, CertDirPlaceholder) normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, CertDirPlaceholder)
@@ -312,6 +323,7 @@ type ExecutorOptions struct {
RouteConfigPath string RouteConfigPath string
CertDir string CertDir string
NginxCertDir string NginxCertDir string
OpenrestyObservabilityPort int
} }
func NewExecutor(options ExecutorOptions) Executor { func NewExecutor(options ExecutorOptions) Executor {
@@ -342,6 +354,7 @@ func NewExecutor(options ExecutorOptions) Executor {
RouteConfigDir: routeConfigDir, RouteConfigDir: routeConfigDir,
CertDir: certDir, CertDir: certDir,
NginxCertDir: options.NginxCertDir, NginxCertDir: options.NginxCertDir,
OpenrestyObservabilityPort: options.OpenrestyObservabilityPort,
Runner: runner, Runner: runner,
} }
} }
@@ -588,7 +601,11 @@ func (m *Manager) supportFileTargetPath(relativePath string) (string, error) {
if strings.TrimSpace(m.CertDir) == "" { if strings.TrimSpace(m.CertDir) == "" {
return "", errors.New("cert dir 不能为空") return "", errors.New("cert dir 不能为空")
} }
normalizedPath := filepath.Clean(filepath.FromSlash(strings.TrimSpace(relativePath))) candidate := strings.TrimSpace(relativePath)
if strings.Contains(candidate, `\`) {
candidate = strings.ReplaceAll(candidate, `\`, "/")
}
normalizedPath := filepath.Clean(filepath.FromSlash(candidate))
if normalizedPath == "." || normalizedPath == "" { if normalizedPath == "." || normalizedPath == "" {
return "", errors.New("support file path 不能为空") return "", errors.New("support file path 不能为空")
} }
@@ -621,6 +638,12 @@ func (m *Manager) renderMainConfig(content string) string {
if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" { if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" {
rendered = strings.ReplaceAll(rendered, AccessLogPlaceholder, accessLogPath) rendered = strings.ReplaceAll(rendered, AccessLogPlaceholder, accessLogPath)
} }
if luaDir := m.luaRuntimePath(); luaDir != "" {
rendered = strings.ReplaceAll(rendered, LuaDirPlaceholder, luaDir)
}
if m.OpenrestyObservabilityPort > 0 {
rendered = strings.ReplaceAll(rendered, ObservabilityPortPlaceholder, fmt.Sprintf("%d", m.OpenrestyObservabilityPort))
}
return rendered return rendered
} }
@@ -639,6 +662,13 @@ func (m *Manager) accessLogRuntimePath() string {
return filepath.ToSlash(filepath.Join(filepath.Dir(includePath), "atsflare_access.log")) return filepath.ToSlash(filepath.Join(filepath.Dir(includePath), "atsflare_access.log"))
} }
func (m *Manager) luaRuntimePath() string {
if strings.TrimSpace(m.NginxCertDir) == "" {
return ""
}
return filepath.ToSlash(m.NginxCertDir)
}
func checksum(content string) string { func checksum(content string) string {
sum := sha256.Sum256([]byte(content)) sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
@@ -217,6 +217,7 @@ func TestDockerExecutorRunContainerMountsManagedFiles(t *testing.T) {
RouteConfigDir: routeConfigDir, RouteConfigDir: routeConfigDir,
CertDir: certDir, CertDir: certDir,
NginxCertDir: "/etc/nginx/atsflare-certs", NginxCertDir: "/etc/nginx/atsflare-certs",
OpenrestyObservabilityPort: 18081,
Runner: runner, Runner: runner,
} }
@@ -233,6 +234,7 @@ func TestDockerExecutorRunContainerMountsManagedFiles(t *testing.T) {
"--name", "atsflare-openresty", "--name", "atsflare-openresty",
"-p", "80:80", "-p", "80:80",
"-p", "443:443", "-p", "443:443",
"-p", "127.0.0.1:18081:18081",
"-v", mainConfigPath + ":" + DockerMainConfigPath, "-v", mainConfigPath + ":" + DockerMainConfigPath,
"-v", routeConfigDir + ":/etc/nginx/conf.d", "-v", routeConfigDir + ":/etc/nginx/conf.d",
"-v", certDir + ":/etc/nginx/atsflare-certs", "-v", certDir + ":/etc/nginx/atsflare-certs",
@@ -260,6 +262,7 @@ func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
RouteConfigDir: filepath.Clean("/tmp/routes"), RouteConfigDir: filepath.Clean("/tmp/routes"),
CertDir: filepath.Clean("/tmp/certs"), CertDir: filepath.Clean("/tmp/certs"),
NginxCertDir: "/etc/nginx/atsflare-certs", NginxCertDir: "/etc/nginx/atsflare-certs",
OpenrestyObservabilityPort: 18081,
Runner: runner, Runner: runner,
} }
@@ -286,6 +289,7 @@ func TestNewExecutorUsesAbsoluteDockerMountPath(t *testing.T) {
RouteConfigPath: "./data/etc/nginx/conf.d/atsflare_routes.conf", RouteConfigPath: "./data/etc/nginx/conf.d/atsflare_routes.conf",
CertDir: "./data/etc/nginx/certs", CertDir: "./data/etc/nginx/certs",
NginxCertDir: "/etc/nginx/atsflare-certs", NginxCertDir: "/etc/nginx/atsflare-certs",
OpenrestyObservabilityPort: 18081,
}) })
dockerExecutor, ok := executor.(*DockerExecutor) dockerExecutor, ok := executor.(*DockerExecutor)
@@ -40,7 +40,7 @@ func BuildProfile(cfg *config.Config, stateStore *state.Store) *protocol.NodeSys
return profile return profile
} }
func BuildSnapshot(cfg *config.Config, stateStore *state.Store) *protocol.NodeMetricSnapshot { func BuildSnapshot(cfg *config.Config, stateStore *state.Store, managed *managedOpenRestyMetrics) *protocol.NodeMetricSnapshot {
now := time.Now().UTC() now := time.Now().UTC()
metric := &protocol.NodeMetricSnapshot{ metric := &protocol.NodeMetricSnapshot{
CapturedAtUnix: now.Unix(), CapturedAtUnix: now.Unix(),
@@ -56,6 +56,11 @@ func BuildSnapshot(cfg *config.Config, stateStore *state.Store) *protocol.NodeMe
metric.NetworkRxBytes, metric.NetworkTxBytes = readLinuxNetworkTotals() metric.NetworkRxBytes, metric.NetworkTxBytes = readLinuxNetworkTotals()
metric.DiskReadBytes, metric.DiskWriteBytes = readLinuxDiskTotals() metric.DiskReadBytes, metric.DiskWriteBytes = readLinuxDiskTotals()
if managed != nil {
metric.OpenrestyRxBytes = managed.OpenrestyRxBytes
metric.OpenrestyTxBytes = managed.OpenrestyTxBytes
metric.OpenrestyConnections = managed.OpenrestyConnections
}
if stateStore == nil { if stateStore == nil {
return metric return metric
@@ -0,0 +1,129 @@
package observability
import (
"atsflare-agent/internal/config"
"atsflare-agent/internal/protocol"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
const openRestyObservabilityPath = "/atsflare/observability"
const openRestyStubStatusPath = "/atsflare/stub_status"
var stubStatusActivePattern = regexp.MustCompile(`Active connections:\s+(\d+)`)
type managedOpenRestyMetrics struct {
TrafficReport *protocol.NodeTrafficReport
OpenrestyRxBytes int64
OpenrestyTxBytes int64
OpenrestyConnections int64
}
type openRestyObservabilityResponse struct {
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
WindowEndedAtUnix int64 `json:"window_ended_at_unix"`
RequestCount int64 `json:"request_count"`
ErrorCount int64 `json:"error_count"`
UniqueVisitorCount int64 `json:"unique_visitor_count"`
StatusCodes map[string]int64 `json:"status_codes"`
TopDomains map[string]int64 `json:"top_domains"`
SourceCountries map[string]int64 `json:"source_countries"`
OpenrestyRxBytes int64 `json:"openresty_rx_bytes"`
OpenrestyTxBytes int64 `json:"openresty_tx_bytes"`
}
func CollectManagedOpenRestyMetrics(cfg *config.Config) *managedOpenRestyMetrics {
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}
observabilityResp := openRestyObservabilityResponse{}
if err := fetchLocalJSON(client, baseURL+openRestyObservabilityPath, &observabilityResp); err != nil {
return nil
}
result := &managedOpenRestyMetrics{
TrafficReport: &protocol.NodeTrafficReport{
WindowStartedAtUnix: observabilityResp.WindowStartedAtUnix,
WindowEndedAtUnix: observabilityResp.WindowEndedAtUnix,
RequestCount: observabilityResp.RequestCount,
ErrorCount: observabilityResp.ErrorCount,
UniqueVisitorCount: observabilityResp.UniqueVisitorCount,
StatusCodes: normalizeCountMap(observabilityResp.StatusCodes),
TopDomains: normalizeCountMap(observabilityResp.TopDomains),
SourceCountries: normalizeCountMap(observabilityResp.SourceCountries),
},
OpenrestyRxBytes: observabilityResp.OpenrestyRxBytes,
OpenrestyTxBytes: observabilityResp.OpenrestyTxBytes,
}
if text, err := fetchLocalText(client, baseURL+openRestyStubStatusPath); err == nil {
result.OpenrestyConnections = parseStubStatusActiveConnections(text)
}
return result
}
func fetchLocalJSON(client *http.Client, url string, target any) error {
resp, err := client.Get(url)
if err != nil {
return err
}
defer 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)
}
func fetchLocalText(client *http.Client, url string) (string, error) {
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected local stub status: %s", resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(data), nil
}
func parseStubStatusActiveConnections(raw string) int64 {
matches := stubStatusActivePattern.FindStringSubmatch(raw)
if len(matches) != 2 {
return 0
}
value, err := strconv.ParseInt(matches[1], 10, 64)
if err != nil {
return 0
}
return value
}
func normalizeCountMap(values map[string]int64) map[string]int64 {
if len(values) == 0 {
return map[string]int64{}
}
result := make(map[string]int64, len(values))
for key, value := range values {
key = strings.TrimSpace(key)
if key == "" || value <= 0 {
continue
}
result[key] = value
}
return result
}
@@ -0,0 +1,82 @@
package observability
import (
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"atsflare-agent/internal/config"
)
func TestCollectManagedOpenRestyMetrics(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen failed: %v", err)
}
port := listener.Addr().(*net.TCPAddr).Port
mux := http.NewServeMux()
mux.HandleFunc(openRestyObservabilityPath, func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{"window_started_at_unix":1710403200,"window_ended_at_unix":1710403210,"request_count":12,"error_count":2,"unique_visitor_count":5,"status_codes":{"200":10,"502":2},"top_domains":{"app.example.com":9,"api.example.com":3},"source_countries":{},"openresty_rx_bytes":4096,"openresty_tx_bytes":8192}`))
})
mux.HandleFunc(openRestyStubStatusPath, func(writer http.ResponseWriter, request *http.Request) {
_, _ = writer.Write([]byte("Active connections: 7 \nserver accepts handled requests\n 10 10 12 \nReading: 1 Writing: 2 Waiting: 4 \n"))
})
server := httptest.NewUnstartedServer(mux)
server.Listener = listener
server.Start()
defer server.Close()
metrics := CollectManagedOpenRestyMetrics(&config.Config{
OpenrestyObservabilityPort: port,
})
if metrics == nil || metrics.TrafficReport == nil {
t.Fatalf("expected managed openresty metrics, got %+v", metrics)
}
if metrics.TrafficReport.RequestCount != 12 || metrics.TrafficReport.ErrorCount != 2 {
t.Fatalf("unexpected traffic report: %+v", metrics.TrafficReport)
}
if metrics.OpenrestyRxBytes != 4096 || metrics.OpenrestyTxBytes != 8192 {
t.Fatalf("unexpected openresty byte counters: %+v", metrics)
}
if metrics.OpenrestyConnections != 7 {
t.Fatalf("unexpected openresty connections: %+v", metrics)
}
}
func TestParseStubStatusActiveConnections(t *testing.T) {
if value := parseStubStatusActiveConnections("Active connections: 19\n"); value != 19 {
t.Fatalf("unexpected active connections: %d", value)
}
}
func TestNormalizeCountMapDropsEmptyKeys(t *testing.T) {
normalized := normalizeCountMap(map[string]int64{
"": 4,
" 200 ": 3,
"app.example.com": 0,
})
if len(normalized) != 1 || normalized["200"] != 3 {
t.Fatalf("unexpected normalized map: %+v", normalized)
}
}
func TestCollectManagedOpenRestyMetricsHandlesUnavailableEndpoint(t *testing.T) {
cfg := &config.Config{OpenrestyObservabilityPort: 1}
if metrics := CollectManagedOpenRestyMetrics(cfg); metrics != nil {
t.Fatalf("expected nil metrics for unavailable endpoint, got %+v", metrics)
}
}
func TestOpenRestyObservabilityPathsAreStable(t *testing.T) {
if !strings.HasPrefix(openRestyObservabilityPath, "/atsflare/") {
t.Fatalf("unexpected observability path: %s", openRestyObservabilityPath)
}
if !strings.HasPrefix(openRestyStubStatusPath, "/atsflare/") {
t.Fatalf("unexpected stub status path: %s", openRestyStubStatusPath)
}
}
+74 -13
View File
@@ -10,6 +10,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -23,6 +24,8 @@ type accessLogRecord struct {
Status int `json:"status"` Status int `json:"status"`
} }
var combinedAccessLogPattern = regexp.MustCompile(`^(\S+)\s+\S+\s+\S+\s+\[([^\]]+)\]\s+"[^"]*"\s+(\d{3})\s+\S+`)
type trafficAggregate struct { type trafficAggregate struct {
windowStartedAt time.Time windowStartedAt time.Time
windowEndedAt time.Time windowEndedAt time.Time
@@ -33,7 +36,10 @@ type trafficAggregate struct {
visitors map[string]struct{} visitors map[string]struct{}
} }
func BuildTrafficReport(cfg *config.Config, stateStore *state.Store) *protocol.NodeTrafficReport { func BuildTrafficReport(cfg *config.Config, stateStore *state.Store, managed *managedOpenRestyMetrics) *protocol.NodeTrafficReport {
if managed != nil && managed.TrafficReport != nil && managed.TrafficReport.RequestCount > 0 {
return managed.TrafficReport
}
if cfg == nil || stateStore == nil { if cfg == nil || stateStore == nil {
return nil return nil
} }
@@ -115,21 +121,16 @@ func (aggregate *trafficAggregate) consume(line []byte) {
return return
} }
var record accessLogRecord record, ok := parseAccessLogRecord(trimmed)
if err := json.Unmarshal([]byte(trimmed), &record); err != nil { if !ok {
return return
} }
timestamp, err := parseAccessLogTime(record.Timestamp) if aggregate.windowStartedAt.IsZero() || record.Timestamp.Before(aggregate.windowStartedAt) {
if err != nil { aggregate.windowStartedAt = record.Timestamp
return
} }
if aggregate.windowEndedAt.IsZero() || record.Timestamp.After(aggregate.windowEndedAt) {
if aggregate.windowStartedAt.IsZero() || timestamp.Before(aggregate.windowStartedAt) { aggregate.windowEndedAt = record.Timestamp
aggregate.windowStartedAt = timestamp
}
if aggregate.windowEndedAt.IsZero() || timestamp.After(aggregate.windowEndedAt) {
aggregate.windowEndedAt = timestamp
} }
aggregate.requestCount++ aggregate.requestCount++
@@ -147,6 +148,58 @@ func (aggregate *trafficAggregate) consume(line []byte) {
} }
} }
type parsedAccessLogRecord struct {
Timestamp time.Time
Host string
RemoteAddr string
Status int
}
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
}
return parsedAccessLogRecord{
Timestamp: timestamp,
Host: strings.TrimSpace(record.Host),
RemoteAddr: strings.TrimSpace(record.RemoteAddr),
Status: record.Status,
}, true
}
func parseCombinedAccessLogRecord(raw string) (parsedAccessLogRecord, bool) {
matches := combinedAccessLogPattern.FindStringSubmatch(raw)
if len(matches) != 4 {
return parsedAccessLogRecord{}, false
}
timestamp, err := parseAccessLogTime(matches[2])
if err != nil {
return parsedAccessLogRecord{}, false
}
status, err := strconv.Atoi(matches[3])
if err != nil {
return parsedAccessLogRecord{}, false
}
return parsedAccessLogRecord{
Timestamp: timestamp,
RemoteAddr: strings.TrimSpace(matches[1]),
Status: status,
}, true
}
func (aggregate *trafficAggregate) report() *protocol.NodeTrafficReport { func (aggregate *trafficAggregate) report() *protocol.NodeTrafficReport {
if aggregate.requestCount == 0 || aggregate.windowStartedAt.IsZero() || aggregate.windowEndedAt.IsZero() { if aggregate.requestCount == 0 || aggregate.windowStartedAt.IsZero() || aggregate.windowEndedAt.IsZero() {
return nil return nil
@@ -165,7 +218,15 @@ func (aggregate *trafficAggregate) report() *protocol.NodeTrafficReport {
} }
func parseAccessLogTime(value string) (time.Time, error) { func parseAccessLogTime(value string) (time.Time, error) {
return time.Parse(time.RFC3339, strings.TrimSpace(value)) 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)
} }
func cloneTrafficCounts(values map[string]int64, limit int) map[string]int64 { func cloneTrafficCounts(values map[string]int64, limit int) map[string]int64 {
@@ -26,7 +26,7 @@ func TestBuildTrafficReportAggregatesManagedAccessLog(t *testing.T) {
} }
stateStore := state.NewStore(filepath.Join(tempDir, "state.json")) stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
report := BuildTrafficReport(&config.Config{RouteConfigPath: routeConfigPath}, stateStore) report := BuildTrafficReport(&config.Config{RouteConfigPath: routeConfigPath}, stateStore, nil)
if report == nil { if report == nil {
t.Fatal("expected traffic report") t.Fatal("expected traffic report")
} }
@@ -48,7 +48,7 @@ func TestBuildTrafficReportAggregatesManagedAccessLog(t *testing.T) {
t.Fatalf("unexpected access log offset: %d", snapshot.AccessLogOffset) t.Fatalf("unexpected access log offset: %d", snapshot.AccessLogOffset)
} }
secondReport := BuildTrafficReport(&config.Config{RouteConfigPath: routeConfigPath}, stateStore) secondReport := BuildTrafficReport(&config.Config{RouteConfigPath: routeConfigPath}, stateStore, nil)
if secondReport != nil { if secondReport != nil {
t.Fatalf("expected no report without appended lines, got %+v", secondReport) t.Fatalf("expected no report without appended lines, got %+v", secondReport)
} }
@@ -70,8 +70,40 @@ func TestBuildTrafficReportResetsOffsetAfterTruncate(t *testing.T) {
t.Fatalf("Save failed: %v", err) t.Fatalf("Save failed: %v", err)
} }
report := BuildTrafficReport(&config.Config{RouteConfigPath: routeConfigPath}, stateStore) report := BuildTrafficReport(&config.Config{RouteConfigPath: routeConfigPath}, stateStore, nil)
if report == nil || report.RequestCount != 1 { if report == nil || report.RequestCount != 1 {
t.Fatalf("expected one request after truncate reset, got %+v", report) t.Fatalf("expected one request after truncate reset, got %+v", report)
} }
} }
func TestBuildTrafficReportParsesCombinedAccessLog(t *testing.T) {
tempDir := t.TempDir()
routeConfigPath := filepath.Join(tempDir, "conf.d", "atsflare_routes.conf")
if err := os.MkdirAll(filepath.Dir(routeConfigPath), 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
logPath := filepath.Join(filepath.Dir(routeConfigPath), "atsflare_access.log")
content := []byte(
"10.0.0.1 - - [14/Mar/2026:08:00:00 +0000] \"GET / HTTP/1.1\" 200 123 \"-\" \"curl/8.0\"\n" +
"10.0.0.2 - - [14/Mar/2026:08:00:05 +0000] \"GET /healthz HTTP/1.1\" 502 64 \"-\" \"curl/8.0\"\n" +
"10.0.0.1 - - [14/Mar/2026:08:00:10 +0000] \"GET /api HTTP/1.1\" 200 256 \"-\" \"curl/8.0\"\n",
)
if err := os.WriteFile(logPath, content, 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
report := BuildTrafficReport(&config.Config{RouteConfigPath: routeConfigPath}, stateStore, nil)
if report == nil {
t.Fatal("expected traffic report from combined access log")
}
if report.RequestCount != 3 || report.ErrorCount != 1 || report.UniqueVisitorCount != 2 {
t.Fatalf("unexpected combined log counters: %+v", report)
}
if report.StatusCodes["200"] != 2 || report.StatusCodes["502"] != 1 {
t.Fatalf("unexpected combined log status codes: %+v", report.StatusCodes)
}
if len(report.TopDomains) != 0 {
t.Fatalf("expected combined access log to omit top domains when host is unavailable, got %+v", report.TopDomains)
}
}
+10 -3
View File
@@ -118,6 +118,8 @@ const (
nginxCertDirPlaceholder = "__ATSF_CERT_DIR__" nginxCertDirPlaceholder = "__ATSF_CERT_DIR__"
nginxRouteConfigPlaceholder = "__ATSF_ROUTE_CONFIG__" nginxRouteConfigPlaceholder = "__ATSF_ROUTE_CONFIG__"
nginxAccessLogPlaceholder = "__ATSF_ACCESS_LOG__" nginxAccessLogPlaceholder = "__ATSF_ACCESS_LOG__"
nginxLuaDirPlaceholder = "__ATSF_LUA_DIR__"
nginxObservabilityPortPlaceholder = "__ATSF_OBSERVABILITY_PORT__"
) )
var requiredMainConfigTemplatePlaceholders = []string{ var requiredMainConfigTemplatePlaceholders = []string{
@@ -351,6 +353,7 @@ func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
supportFiles = append(supportFiles, buildOpenRestyObservabilitySupportFiles()...)
mainConfig := renderMainConfig(openRestyConfig) mainConfig := renderMainConfig(openRestyConfig)
return &configBundle{ return &configBundle{
Routes: routes, Routes: routes,
@@ -675,17 +678,21 @@ func renderTemplateDirective(enabled bool, statement string) string {
} }
func renderOpenRestyCacheTemplateBlock(cfg openRestyConfigSnapshot) string { func renderOpenRestyCacheTemplateBlock(cfg openRestyConfigSnapshot) string {
lines := make([]string, 0, 8)
if !cfg.CacheEnabled { if !cfg.CacheEnabled {
return "" lines = append(lines, renderOpenRestyObservabilityTemplateBlock())
return strings.Join(lines, "")
} }
return strings.Join([]string{ lines = append(lines, strings.Join([]string{
fmt.Sprintf(" proxy_cache_path %s levels=%s keys_zone=atsflare_cache:10m inactive=%s max_size=%s;", cfg.CachePath, cfg.CacheLevels, cfg.CacheInactive, cfg.CacheMaxSize), fmt.Sprintf(" proxy_cache_path %s levels=%s keys_zone=atsflare_cache:10m inactive=%s max_size=%s;", cfg.CachePath, cfg.CacheLevels, cfg.CacheInactive, cfg.CacheMaxSize),
fmt.Sprintf(" proxy_cache_key \"%s\";", cfg.CacheKeyTemplate), fmt.Sprintf(" proxy_cache_key \"%s\";", cfg.CacheKeyTemplate),
fmt.Sprintf(" proxy_cache_lock %s;", onOff(cfg.CacheLockEnabled)), fmt.Sprintf(" proxy_cache_lock %s;", onOff(cfg.CacheLockEnabled)),
fmt.Sprintf(" proxy_cache_lock_timeout %s;", cfg.CacheLockTimeout), fmt.Sprintf(" proxy_cache_lock_timeout %s;", cfg.CacheLockTimeout),
fmt.Sprintf(" proxy_cache_use_stale %s;", cfg.CacheUseStale), fmt.Sprintf(" proxy_cache_use_stale %s;", cfg.CacheUseStale),
"", "",
}, "\n") }, "\n"))
lines = append(lines, renderOpenRestyObservabilityTemplateBlock())
return strings.Join(lines, "")
} }
func onOff(value bool) string { func onOff(value bool) string {
+12
View File
@@ -57,6 +57,12 @@ func TestCreateTLSCertificateAndRenderHTTPSConfig(t *testing.T) {
if !strings.Contains(result.Version.MainConfig, "access_log __ATSF_ACCESS_LOG__ atsflare_json;") { if !strings.Contains(result.Version.MainConfig, "access_log __ATSF_ACCESS_LOG__ atsflare_json;") {
t.Fatal("expected main config to include managed access log placeholder") t.Fatal("expected main config to include managed access log placeholder")
} }
if !strings.Contains(result.Version.MainConfig, "log_by_lua_file __ATSF_LUA_DIR__/observability/log.lua;") {
t.Fatal("expected main config to include managed openresty lua log hook")
}
if !strings.Contains(result.Version.MainConfig, "listen 127.0.0.1:__ATSF_OBSERVABILITY_PORT__;") {
t.Fatal("expected main config to include managed openresty observability port placeholder")
}
if !strings.Contains(result.Version.RenderedConfig, "listen 443 ssl;") { if !strings.Contains(result.Version.RenderedConfig, "listen 443 ssl;") {
t.Fatal("expected rendered config to include https server block") t.Fatal("expected rendered config to include https server block")
} }
@@ -69,6 +75,9 @@ func TestCreateTLSCertificateAndRenderHTTPSConfig(t *testing.T) {
if !strings.Contains(result.Version.SupportFilesJSON, ".crt") || !strings.Contains(result.Version.SupportFilesJSON, ".key") { if !strings.Contains(result.Version.SupportFilesJSON, ".crt") || !strings.Contains(result.Version.SupportFilesJSON, ".key") {
t.Fatal("expected support files to contain certificate and key") t.Fatal("expected support files to contain certificate and key")
} }
if !strings.Contains(result.Version.SupportFilesJSON, "observability/log.lua") || !strings.Contains(result.Version.SupportFilesJSON, "observability/read.lua") {
t.Fatal("expected support files to contain managed openresty observability lua scripts")
}
} }
func TestCreateProxyRouteRejectsHTTPSWithoutCertificate(t *testing.T) { func TestCreateProxyRouteRejectsHTTPSWithoutCertificate(t *testing.T) {
@@ -187,6 +196,9 @@ func TestPreviewAndDiffConfigVersion(t *testing.T) {
if !strings.Contains(preview.MainConfig, "include __ATSF_ROUTE_CONFIG__;") { if !strings.Contains(preview.MainConfig, "include __ATSF_ROUTE_CONFIG__;") {
t.Fatal("expected preview main config to include managed route config placeholder") t.Fatal("expected preview main config to include managed route config placeholder")
} }
if !strings.Contains(preview.MainConfig, "log_by_lua_file __ATSF_LUA_DIR__/observability/log.lua;") {
t.Fatal("expected preview main config to include managed openresty lua log hook")
}
if !strings.Contains(preview.RenderedConfig, `proxy_set_header X-Release "candidate";`) { if !strings.Contains(preview.RenderedConfig, `proxy_set_header X-Release "candidate";`) {
t.Fatal("expected preview config to include modified custom header") t.Fatal("expected preview config to include modified custom header")
} }
@@ -0,0 +1,212 @@
package service
import "fmt"
const (
openRestyObservabilitySupportDir = "observability"
openRestyObservabilityInitLuaPath = openRestyObservabilitySupportDir + "/init.lua"
openRestyObservabilityLogLuaPath = openRestyObservabilitySupportDir + "/log.lua"
openRestyObservabilityReadLuaPath = openRestyObservabilitySupportDir + "/read.lua"
openRestyObservabilityWindowTTL = 7200
)
const openRestyObservabilityInitLua = `local dict = ngx.shared.atsflare_observability
if not dict then
return
end
local now = ngx.time()
local current_window = dict:get("current_window")
if not current_window then
dict:set("current_window", now)
dict:set("window_started_at:" .. now, now)
end
`
const openRestyObservabilityLogLua = `local dict = ngx.shared.atsflare_observability
if not dict then
return
end
local ttl = ` + "7200" + `
local current_window = dict:get("current_window")
local now = ngx.time()
if not current_window then
current_window = now
dict:set("current_window", current_window)
dict:set("window_started_at:" .. current_window, now)
end
local function ensure_counter(key)
dict:add(key, 0, ttl)
end
local function incr(key, delta)
ensure_counter(key)
local value, err = dict:incr(key, delta)
if not value and err == "not found" then
dict:set(key, delta, ttl)
end
end
local function remember_value(list_key, marker_key, value)
if value == "" then
return
end
if not dict:add(marker_key, 1, ttl) then
return
end
local existing = dict:get(list_key)
if not existing or existing == "" then
dict:set(list_key, value, ttl)
return
end
dict:set(list_key, existing .. "\n" .. value, ttl)
end
local window_prefix = tostring(current_window)
incr("request_count:" .. window_prefix, 1)
local status = tostring(ngx.status or 0)
if status ~= "0" then
incr("status:" .. window_prefix .. ":" .. status, 1)
remember_value(
"status_keys:" .. window_prefix,
"status_marker:" .. window_prefix .. ":" .. status,
status
)
if tonumber(status) and tonumber(status) >= 500 then
incr("error_count:" .. window_prefix, 1)
end
end
local host = tostring(ngx.var.host or "")
if host ~= "" then
incr("domain:" .. window_prefix .. ":" .. host, 1)
remember_value(
"domain_keys:" .. window_prefix,
"domain_marker:" .. window_prefix .. ":" .. host,
host
)
end
local remote_addr = tostring(ngx.var.binary_remote_addr or ngx.var.remote_addr or "")
if remote_addr ~= "" and dict:add("visitor:" .. window_prefix .. ":" .. remote_addr, 1, ttl) then
incr("unique_visitor_count:" .. window_prefix, 1)
end
local request_length = tonumber(ngx.var.request_length) or 0
if request_length > 0 then
incr("openresty_rx_bytes:" .. window_prefix, request_length)
end
local bytes_sent = tonumber(ngx.var.bytes_sent) or tonumber(ngx.var.body_bytes_sent) or 0
if bytes_sent > 0 then
incr("openresty_tx_bytes:" .. window_prefix, bytes_sent)
end
`
const openRestyObservabilityReadLua = `local cjson = require "cjson.safe"
local dict = ngx.shared.atsflare_observability
if not dict then
ngx.status = ngx.HTTP_SERVICE_UNAVAILABLE
ngx.say(cjson.encode({ message = "shared dict unavailable" }))
return
end
local now = ngx.time()
local current_window = dict:get("current_window")
if not current_window then
current_window = now
dict:set("current_window", current_window)
dict:set("window_started_at:" .. current_window, now)
end
local function read_counter(key)
return tonumber(dict:get(key) or 0) or 0
end
local function read_map(window_id, prefix, list_key)
local result = {}
local raw = dict:get(list_key .. ":" .. window_id)
if not raw or raw == "" then
return result
end
for value in string.gmatch(raw, "[^\n]+") do
result[value] = read_counter(prefix .. ":" .. window_id .. ":" .. value)
end
return result
end
local payload = {
window_started_at_unix = read_counter("window_started_at:" .. current_window),
window_ended_at_unix = now,
request_count = read_counter("request_count:" .. current_window),
error_count = read_counter("error_count:" .. current_window),
unique_visitor_count = read_counter("unique_visitor_count:" .. current_window),
status_codes = read_map(current_window, "status", "status_keys"),
top_domains = read_map(current_window, "domain", "domain_keys"),
source_countries = {},
openresty_rx_bytes = read_counter("openresty_rx_bytes:" .. current_window),
openresty_tx_bytes = read_counter("openresty_tx_bytes:" .. current_window)
}
local next_window = now
if next_window <= current_window then
next_window = current_window + 1
end
dict:set("current_window", next_window)
dict:set("window_started_at:" .. next_window, now)
ngx.header.content_type = "application/json"
ngx.say(cjson.encode(payload))
`
func buildOpenRestyObservabilitySupportFiles() []SupportFile {
return []SupportFile{
{Path: openRestyObservabilityInitLuaPath, Content: openRestyObservabilityInitLua},
{Path: openRestyObservabilityLogLuaPath, Content: openRestyObservabilityLogLua},
{Path: openRestyObservabilityReadLuaPath, Content: openRestyObservabilityReadLua},
}
}
func renderOpenRestyObservabilityTemplateBlock() string {
return stringsJoinLines(
" lua_shared_dict atsflare_observability 10m;",
fmt.Sprintf(" init_worker_by_lua_file %s/%s;", nginxLuaDirPlaceholder, openRestyObservabilityInitLuaPath),
fmt.Sprintf(" log_by_lua_file %s/%s;", nginxLuaDirPlaceholder, openRestyObservabilityLogLuaPath),
"",
fmt.Sprintf(" server {"),
fmt.Sprintf(" listen 127.0.0.1:%s;", nginxObservabilityPortPlaceholder),
" server_name atsflare-observability;",
" access_log off;",
" allow 127.0.0.1;",
" deny all;",
"",
" location = /atsflare/observability {",
" default_type application/json;",
fmt.Sprintf(" content_by_lua_file %s/%s;", nginxLuaDirPlaceholder, openRestyObservabilityReadLuaPath),
" }",
"",
" location = /atsflare/stub_status {",
" stub_status;",
" }",
" }",
"",
)
}
func stringsJoinLines(lines ...string) string {
if len(lines) == 0 {
return ""
}
result := ""
for index, line := range lines {
if index > 0 {
result += "\n"
}
result += line
}
return result + "\n"
}
@@ -1350,7 +1350,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
</p> </p>
{node.latest_apply_checksum ? ( {node.latest_apply_checksum ? (
<div className="space-y-1 text-sm text-[var(--foreground-secondary)]"> <div className="space-y-1 text-sm text-[var(--foreground-secondary)]">
<p>目标 Checksum:{node.latest_apply_checksum}</p>
<p>支持文件:{node.latest_support_file_count}</p> <p>支持文件:{node.latest_support_file_count}</p>
</div> </div>
) : null} ) : null}
+9
View File
@@ -238,6 +238,7 @@ go run ./cmd/agent -config ./agent.json
"data_dir": "./data", "data_dir": "./data",
"openresty_container_name": "atsflare-openresty", "openresty_container_name": "atsflare-openresty",
"openresty_docker_image": "openresty/openresty:alpine", "openresty_docker_image": "openresty/openresty:alpine",
"openresty_observability_port": 18081,
"heartbeat_interval": 10000, "heartbeat_interval": 10000,
"request_timeout": 10000 "request_timeout": 10000
} }
@@ -257,6 +258,7 @@ go run ./cmd/agent -config ./agent.json
"route_config_path": "/usr/local/openresty/nginx/conf/conf.d/atsflare_routes.conf", "route_config_path": "/usr/local/openresty/nginx/conf/conf.d/atsflare_routes.conf",
"cert_dir": "/usr/local/openresty/nginx/conf/certs", "cert_dir": "/usr/local/openresty/nginx/conf/certs",
"openresty_cert_dir": "/usr/local/openresty/nginx/conf/certs", "openresty_cert_dir": "/usr/local/openresty/nginx/conf/certs",
"openresty_observability_port": 18081,
"state_path": "./data/agent-state.json", "state_path": "./data/agent-state.json",
"heartbeat_interval": 10000, "heartbeat_interval": 10000,
"request_timeout": 10000 "request_timeout": 10000
@@ -275,6 +277,7 @@ go run ./cmd/agent -config ./agent.json
| `openresty_path` | 本机 OpenResty 可执行文件路径;设置后按本机 OpenResty 模式运行 | 否 | 空;未设置时按 Docker OpenResty 模式处理 | `/usr/local/openresty/nginx/sbin/openresty` | | `openresty_path` | 本机 OpenResty 可执行文件路径;设置后按本机 OpenResty 模式运行 | 否 | 空;未设置时按 Docker OpenResty 模式处理 | `/usr/local/openresty/nginx/sbin/openresty` |
| `openresty_container_name` | Docker 模式下的 OpenResty 容器名 | 否 | `atsflare-openresty` | `atsflare-openresty` | | `openresty_container_name` | Docker 模式下的 OpenResty 容器名 | 否 | `atsflare-openresty` | `atsflare-openresty` |
| `openresty_docker_image` | Docker 模式下用于初始化/管理的 OpenResty 镜像 | 否 | `openresty/openresty:alpine` | `openresty/openresty:alpine` | | `openresty_docker_image` | Docker 模式下用于初始化/管理的 OpenResty 镜像 | 否 | `openresty/openresty:alpine` | `openresty/openresty:alpine` |
| `openresty_observability_port` | Agent 注入的 OpenResty 本地观测端口;用于 heartbeat 前读取 Lua 窗口指标和 `stub_status`,默认仅监听 `127.0.0.1` | 否 | `18081` | `18081` |
| `docker_binary` | Docker 可执行文件名或路径 | 否 | `docker` | `/usr/bin/docker` | | `docker_binary` | Docker 可执行文件名或路径 | 否 | `docker` | `/usr/bin/docker` |
| `data_dir` | Agent 数据目录,用于存储托管配置、证书和状态文件 | 否 | 配置文件所在目录下的 `data` 子目录 | `./data` | | `data_dir` | Agent 数据目录,用于存储托管配置、证书和状态文件 | 否 | 配置文件所在目录下的 `data` 子目录 | `./data` |
| `main_config_path` | 第五版主配置接管时 OpenResty 主配置文件写入路径 | 第五版本机模式建议必填 | Docker 模式可使用受管默认路径;本机模式建议显式设置 | `/usr/local/openresty/nginx/conf/nginx.conf` | | `main_config_path` | 第五版主配置接管时 OpenResty 主配置文件写入路径 | 第五版本机模式建议必填 | Docker 模式可使用受管默认路径;本机模式建议显式设置 | `/usr/local/openresty/nginx/conf/nginx.conf` |
@@ -293,6 +296,7 @@ go run ./cmd/agent -config ./agent.json
* Go duration 字符串,例如 `"30s"` * Go duration 字符串,例如 `"30s"`
* `node_name` 与 `node_ip` 未填写时会自动探测;若自动探测失败,配置校验会报错 * `node_name` 与 `node_ip` 未填写时会自动探测;若自动探测失败,配置校验会报错
* 未配置 `openresty_path` 时,默认为 Docker OpenResty 模式 * 未配置 `openresty_path` 时,默认为 Docker OpenResty 模式
* `openresty_observability_port` 默认仅绑定本地回环地址;若节点本机已有端口冲突,可改为其他未占用端口
* 配置保存时,`agent_version`、`nginx_version` 由程序运行时维护,不需要写入 JSON * 配置保存时,`agent_version`、`nginx_version` 由程序运行时维护,不需要写入 JSON
* 第五版主配置接管完成后,本机模式下应优先通过 `main_config_path` 由 Agent 写入受管主配置,而不是依赖节点手工维护 include 规则 * 第五版主配置接管完成后,本机模式下应优先通过 `main_config_path` 由 Agent 写入受管主配置,而不是依赖节点手工维护 include 规则
@@ -313,6 +317,11 @@ Docker OpenResty 模式下:
| --- | --- | | --- | --- |
| `openresty_cert_dir` | `/etc/nginx/atsflare-certs` | | `openresty_cert_dir` | `/etc/nginx/atsflare-certs` |
补充说明:
* Agent 当前会随受管配置一并向 OpenResty 注入 Lua 观测脚本,并在每次 heartbeat 前通过 `http://127.0.0.1:<openresty_observability_port>/atsflare/observability` 读取最近窗口请求指标
* 同一端口还会暴露仅本机可访问的 `stub_status`,用于采集 OpenResty 活动连接数
### 2.5 Agent 启动示例 ### 2.5 Agent 启动示例
#### Docker OpenResty 模式 #### Docker OpenResty 模式
+13 -4
View File
@@ -154,6 +154,7 @@ swag init -g main.go -o docs
"data_dir": "./data", "data_dir": "./data",
"openresty_container_name": "atsflare-openresty", "openresty_container_name": "atsflare-openresty",
"openresty_docker_image": "openresty/openresty:alpine", "openresty_docker_image": "openresty/openresty:alpine",
"openresty_observability_port": 18081,
"heartbeat_interval": 10000, "heartbeat_interval": 10000,
"request_timeout": 10000 "request_timeout": 10000
} }
@@ -168,6 +169,7 @@ swag init -g main.go -o docs
"data_dir": "./data", "data_dir": "./data",
"openresty_container_name": "atsflare-openresty", "openresty_container_name": "atsflare-openresty",
"openresty_docker_image": "openresty/openresty:alpine", "openresty_docker_image": "openresty/openresty:alpine",
"openresty_observability_port": 18081,
"heartbeat_interval": 10000, "heartbeat_interval": 10000,
"request_timeout": 10000 "request_timeout": 10000
} }
@@ -182,6 +184,7 @@ swag init -g main.go -o docs
* 若 `agent_token` 为空且 `discovery_token` 存在,Agent 会自动注册并写回新的专属 `agent_token` * 若 `agent_token` 为空且 `discovery_token` 存在,Agent 会自动注册并写回新的专属 `agent_token`
* `node_name` 与 `node_ip` 可省略,未填写时自动探测 * `node_name` 与 `node_ip` 可省略,未填写时自动探测
* 未配置 `openresty_path` 时,默认使用 Docker OpenResty 容器 * 未配置 `openresty_path` 时,默认使用 Docker OpenResty 容器
* Agent 会在受管 OpenResty 中注入 Lua 观测脚本,并通过 `openresty_observability_port` 对本机暴露最近窗口指标与 `stub_status`
### 3.3 第五版新增部署约束 ### 3.3 第五版新增部署约束
@@ -189,6 +192,7 @@ swag init -g main.go -o docs
* 本机 OpenResty 模式需要为 Agent 显式提供主配置文件写入路径 * 本机 OpenResty 模式需要为 Agent 显式提供主配置文件写入路径
* Docker OpenResty 模式需要保证主配置、路由配置和证书目录位于同一套受管挂载路径中 * Docker OpenResty 模式需要保证主配置、路由配置和证书目录位于同一套受管挂载路径中
* Docker OpenResty 模式会额外挂载一个仅本机可访问的 `127.0.0.1:<openresty_observability_port>` 观测端口,用于 Agent 在 heartbeat 前抓取 Lua 窗口指标
* 节点现存手工维护的主配置如继续保留,必须先迁移为 Server 渲染模板的等价配置,再切换到受管模式 * 节点现存手工维护的主配置如继续保留,必须先迁移为 Server 渲染模板的等价配置,再切换到受管模式
* 主配置切换前必须预留回滚副本,并通过一次 `openresty -t` 失败演练验证回滚 * 主配置切换前必须预留回滚副本,并通过一次 `openresty -t` 失败演练验证回滚
@@ -252,7 +256,8 @@ export LOG_LEVEL='info'
"discovery_token": "replace-with-global-discovery-token", "discovery_token": "replace-with-global-discovery-token",
"data_dir": "./data", "data_dir": "./data",
"openresty_container_name": "atsflare-openresty", "openresty_container_name": "atsflare-openresty",
"openresty_docker_image": "openresty/openresty:alpine" "openresty_docker_image": "openresty/openresty:alpine",
"openresty_observability_port": 18081
} }
``` ```
@@ -260,6 +265,7 @@ export LOG_LEVEL='info'
1. 首次启动后确认 `data/etc/nginx/nginx.conf`、`data/etc/nginx/conf.d/atsflare_routes.conf` 与 `data/etc/nginx/certs` 已由 Agent 创建 1. 首次启动后确认 `data/etc/nginx/nginx.conf`、`data/etc/nginx/conf.d/atsflare_routes.conf` 与 `data/etc/nginx/certs` 已由 Agent 创建
2. 确认容器实际挂载了主配置、路由目录和证书目录 2. 确认容器实际挂载了主配置、路由目录和证书目录
3. 确认宿主机本地可访问 `http://127.0.0.1:18081/atsflare/observability` 与 `http://127.0.0.1:18081/atsflare/stub_status`
3. 在管理端发布一次新版本后,确认节点 `current_version` 追平激活版本 3. 在管理端发布一次新版本后,确认节点 `current_version` 追平激活版本
4. 在节点详情查看“当前目标版本”与“最近应用”,确认主配置/路由配置快照和 checksum 已可见 4. 在节点详情查看“当前目标版本”与“最近应用”,确认主配置/路由配置快照和 checksum 已可见
@@ -273,6 +279,7 @@ docker exec atsflare-openresty openresty -t
说明: 说明:
* `docker inspect` 重点确认主配置文件、`conf.d` 目录和证书目录都来自 Agent 受管路径 * `docker inspect` 重点确认主配置文件、`conf.d` 目录和证书目录都来自 Agent 受管路径
* 观测端口默认只绑定 `127.0.0.1`;若节点已有冲突,可在 `agent.json` 中调整 `openresty_observability_port`
* 若容器名使用默认值,请将上述命令中的名称替换为 `atsflare-openresty` * 若容器名使用默认值,请将上述命令中的名称替换为 `atsflare-openresty`
### 5.3.2 本机 OpenResty 模式最小验证 ### 5.3.2 本机 OpenResty 模式最小验证
@@ -287,7 +294,8 @@ docker exec atsflare-openresty openresty -t
"main_config_path": "/usr/local/openresty/nginx/conf/nginx.conf", "main_config_path": "/usr/local/openresty/nginx/conf/nginx.conf",
"route_config_path": "/usr/local/openresty/nginx/conf/conf.d/atsflare_routes.conf", "route_config_path": "/usr/local/openresty/nginx/conf/conf.d/atsflare_routes.conf",
"cert_dir": "/usr/local/openresty/nginx/conf/certs", "cert_dir": "/usr/local/openresty/nginx/conf/certs",
"openresty_cert_dir": "/usr/local/openresty/nginx/conf/certs" "openresty_cert_dir": "/usr/local/openresty/nginx/conf/certs",
"openresty_observability_port": 18081
} }
``` ```
@@ -295,8 +303,9 @@ docker exec atsflare-openresty openresty -t
1. 发布前先备份 `main_config_path` 与 `route_config_path` 1. 发布前先备份 `main_config_path` 与 `route_config_path`
2. 首次发布后执行 `openresty -t`,确认主配置已由 Server 模板接管且 include 指向 Agent 写入的路由文件 2. 首次发布后执行 `openresty -t`,确认主配置已由 Server 模板接管且 include 指向 Agent 写入的路由文件
3. 再次发布修改后的规则或 OpenResty 参数,确认 `openresty -s reload` 成功且节点版本更新 3. 确认本机可访问 `http://127.0.0.1:18081/atsflare/observability` 与 `http://127.0.0.1:18081/atsflare/stub_status`
4. 在节点详情与应用记录页确认主配置 checksum、路由配置 checksum 和支持文件数已上报 4. 再次发布修改后的规则或 OpenResty 参数,确认 `openresty -s reload` 成功且节点版本更新
5. 在节点详情与应用记录页确认主配置 checksum、路由配置 checksum 和支持文件数已上报
### 5.4 验证管理端状态 ### 5.4 验证管理端状态
+1 -1
View File
@@ -19,7 +19,7 @@
* 已完成 heartbeat 扩展协议与观测数据分层落地,节点已支持上报 `profile`、`snapshot`、`traffic_report`、`health_events` * 已完成 heartbeat 扩展协议与观测数据分层落地,节点已支持上报 `profile`、`snapshot`、`traffic_report`、`health_events`
* 已完成 Server 侧观测模型、入库链路与查询接口,节点观测已拆分为系统画像、资源快照、窗口流量聚合、健康事件 * 已完成 Server 侧观测模型、入库链路与查询接口,节点观测已拆分为系统画像、资源快照、窗口流量聚合、健康事件
* 已完成 Agent 侧真实请求窗口聚合采集,当前通过受管 OpenResty 访问日志做增量读取与窗口聚合,不再只是预留服务端通道 * 已完成 Agent 侧真实请求窗口聚合采集,当前通过受管 OpenResty Lua 观测脚本与本地指标端口输出窗口聚合结果,不再依赖访问日志增量读取
* 已完成节点详情观测接口与页面第一轮改造,当前已支持系统画像、实时资源、运行状态、24 小时趋势、状态码分布、Top Domain 与健康事件时间线 * 已完成节点详情观测接口与页面第一轮改造,当前已支持系统画像、实时资源、运行状态、24 小时趋势、状态码分布、Top Domain 与健康事件时间线
* 已完成首页总览专用聚合接口与首页第一轮改造,当前已支持系统运行总览、风险态势、峰值摘要、24 小时趋势、节点健康列表与活动异常 * 已完成首页总览专用聚合接口与首页第一轮改造,当前已支持系统运行总览、风险态势、峰值摘要、24 小时趋势、节点健康列表与活动异常
* 已完成首页风险态势到节点页的轻量筛选联动,支持从总览跳转到节点页查看离线节点、OpenResty 异常节点与配置落后节点 * 已完成首页风险态势到节点页的轻量筛选联动,支持从总览跳转到节点页查看离线节点、OpenResty 异常节点与配置落后节点