Files
OpenFlare/docs/en/design/observability-data-model.md
T
ryan 454542c1d0 docs(i18n): 恢复并补齐英文版 vitepress,README 默认改为英文
- README 默认英文:README.en.md → README.md(英文为默认),中文移至 README.zh-CN.md,语言切换链接同步
- 恢复被删除的 docs/en/ 英文文档(git 历史 cc5e53c5^),删除 4 篇已废弃文件
- 英文导航 config.ts 对齐中文结构(新增 Deployment/Changelog 侧栏,同步 Guide/Design 条目)
- 翻译 15 篇中文新增文档:guide 5 篇(certificates/pages-usage/proxy-config/uptime-kuma/zone-domain-migration)+ design 10 篇(zone-design/cloudflare-pointing/waf-orchestration/origin-error-page/edge-cache-design/pages-design/logstore/kuma-design/login-captcha/observability 三篇)
- en 首页更新(新增 Pages 特性、tagline 同步);changelog 英文入口指向中文版
- vitepress 构建验证:43 个英文页面全部渲染

注意:29 篇旧英文文档为恢复版,部分内容(如 deployment/server、reference/configuration)可能落后于中文,需后续逐篇同步
2026-08-16 23:18:29 +08:00

770 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Agent Reporting Protocol and Observability Data Model
You will learn: the **data structures** of the refactored Agent heartbeat/WS reports, how the Server **parses and writes** them, and the **target table structures** in ClickHouse / relational DBs.
**No protocol compatibility layer**: Agents upgrade via destroy-and-recreate or binary replacement; old fields are not parsed, old buffers are discarded wholesale.
This design is the **protocol & storage chapter** of [Edge Observability & Business Traffic Stats Refactor](./observability-design.md); implement against the fields and DDL in this document.
**First read the transport overview and examples:** [Observability Transport Model](./observability-transport-model.md).
---
## 1. Design Goals
| Goal | Description |
| --- | --- |
| Agent reports only facts | details + host readings + edge health instant state; no business pre-aggregation |
| One business detail table | access logs are the only L1 write path |
| Aggregation in DB/control plane | hourly summaries come from ClickHouse MV or queries; the Agent never writes summary tables |
| No field overlap | `bytes_sent` = data provided; NIC `network_*` = host; no business `openresty_tx` anymore |
| Evolvable | new fields optional; missing numeric values default to 0; removed legacy protocol fields are not parsed |
---
## 2. Layering and Write Overview
```text
Agent NodePayload (v2)
│
┌───────────────┼───────────────┐
▼ ▼ ▼
access_logs host_metrics edge_health
(L1 details) (L3 readings) (L2 instant)
│ │ │
▼ ▼ ▼
of_node_access_logs of_node_metric_ of_node_edge_health
│ snapshots │
│ │ │
▼ ▼ │
of_access_log_hourly of_node_metric_ │
(MV, Server side) capacity_hourly (MV) │
│ │ │
└─────── admin aggregation API ────┘
Relational DB (PostgreSQL/SQLite): node latest state, Profile, health events (not a detail lake)
```
| Layer | Meaning | Agent Report Block | ClickHouse Fact Table |
| --- | --- | --- | --- |
| L1 | business delivery | `access_logs` | `of_node_access_logs` |
| L2 | edge health | `edge_health` | `of_node_edge_health` |
| L3 | host capacity | `host_metrics` | `of_node_metric_snapshots` |
---
## 3. Agent Report Data Structures (protocol v2)
### 3.1 Top-Level `NodePayload`
Transport: HTTP heartbeat body and WebSocket `status` messages share the same structure.
```json
{
"schema_version": 2,
"node_id": "n_xxx",
"name": "edge-1",
"ip": "1.2.3.4",
"version": "3.3.0",
"ext_version": "",
"current_version": "cfg-checksum-or-version",
"last_error": "",
"profile": { },
"host_metrics": { },
"edge_health": { },
"access_logs": [ ],
"buffered": [ ],
"health_events": [ ],
"waf_ip_group_checksums": { "1": "md5..." }
}
```
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `schema_version` | int | suggested | fixed to `2` (this design) |
| `node_id` | string | ✅ | node ID |
| `name` | string | ✅ | display name |
| `ip` | string | ✅ | reporting IP |
| `version` / `ext_version` | string | ✅ | Agent version |
| `current_version` | string | | locally active config version summary |
| `last_error` | string | | latest sync/runtime error, nullable |
| `openresty_status` | string | ✅ (when OpenResty present) | **latest health-state authoritative field** → written to PG node table |
| `openresty_message` | string | | **latest health-description authoritative field** → written to PG node table (**not into CH**) |
| `profile` | object | | host overview, report on change (may throttle) |
| `host_metrics` | object | suggested each beat | L3 resource snapshot |
| `edge_health` | object | suggested each beat | L2 connection time series + status aligned with top level |
| `access_logs` | array | | this beat's incremental access details |
| `buffered` | array | | offline backfill fact batches (see §3.6) |
| `health_events` | array | | edge health events |
| `waf_ip_group_checksums` | map | | for differential sync, not an observability lake |
**Removed, Server no longer parses (no compatibility layer):**
| Old Field | Disposition |
| --- | --- |
| `traffic_report` | not in the protocol; not stored |
| `openresty_observation` | not present; connections/status go through `edge_health` |
| `snapshot` | not present; only `host_metrics` |
| `buffered_observability` | not present; only `buffered` |
### 3.2 `profile` — Host Overview (low frequency)
Maps to relational `of_node_system_profiles` (or an existing equivalent), **not into the ClickHouse detail lake**.
```json
{
"hostname": "edge-1",
"os_name": "linux",
"os_version": "...",
"kernel_version": "...",
"architecture": "amd64",
"cpu_model": "...",
"cpu_cores": 8,
"total_memory_bytes": 16106127360,
"total_disk_bytes": 107374182400,
"uptime_seconds": 864000,
"reported_at_unix": 1720000000
}
```
| Field | Semantics |
| --- | --- |
| hardware/OS description fields | factual readings |
| `reported_at_unix` | Agent collection time (UTC seconds) |
### 3.3 `host_metrics` — Host Capacity (L3)
**All readings, no 24h business totals.**
NIC/disk bytes are **kernel cumulative counter raw values** (monotonically increasing, may reset on restart); CPU is an instant percentage; memory/disk usage is current usage.
```json
{
"captured_at_unix": 1720000000,
"cpu_usage_percent": 12.5,
"memory_used_bytes": 4294967296,
"memory_total_bytes": 16106127360,
"storage_used_bytes": 50000000000,
"storage_total_bytes": 107374182400,
"disk_read_bytes": 9000000000,
"disk_write_bytes": 12000000000,
"network_rx_bytes": 500000000000,
"network_tx_bytes": 800000000000
}
```
| Field | Type | Semantics | How Server Uses It |
| --- | --- | --- | --- |
| `captured_at_unix` | int64 | sampling time | `captured_at` |
| `cpu_usage_percent` | float | instant CPU% | store directly; average for trends |
| `memory_*` / `storage_*` | int64 | current used/total | store directly; compute usage rate |
| `disk_read_bytes` / `disk_write_bytes` | int64 | **cumulative** IO bytes | store raw; adjacent deltas at query time |
| `network_rx_bytes` / `network_tx_bytes` | int64 | **cumulative** NIC bytes | store raw; adjacent deltas at query time → "host NIC in/outbound" |
> The Agent is **forbidden** from replacing cumulative values with "this period's delta" before reporting (otherwise Server deltas would be wrong).
### 3.4 `edge_health` — OpenResty Edge Health (L2)
**Instant state only, no business throughput.**
```json
{
"captured_at_unix": 1720000000,
"status": "healthy",
"message": "",
"connections": 42
}
```
| Field | Type | Semantics |
| --- | --- | --- |
| `status` | string | `healthy` / `unhealthy` / `unknown` (must match top-level `openresty_status`) |
| `message` | string | status description (may be reported; **only backfills PG latest state, not into CH**) |
| `connections` | int64 | stub_status Active connections |
#### Health-State Authoritative Sources (converged)
| Data | Authoritative Storage | Description |
| --- | --- | --- |
| **Current** OpenResty health + description | **PG node table** `openresty_status` / `openresty_message` | UI badges, lists, alerts use this |
| **Time series** health status + connections | **CH** `of_node_edge_health` (`status`, `connections`) | connection curves / health history; **no message column** |
| Agent report | top-level status/message + `edge_health` | Server normalizes both statuses aligned; message **only written to PG** |
So: "is it unhealthy now" → read PG; "connections over the past 24h" → read CH.
### 3.5 `access_logs[]` — Access Details (L1, single business truth)
Agent: tail access.log → parse JSON lines → report fields as-is (path may be truncated).
```json
{
"logged_at_unix": 1720000001,
"remote_addr": "203.0.113.10",
"host": "www.example.com",
"path": "/api/v1/ping",
"status_code": 200,
"bytes_sent": 1024,
"request_length": 128,
"request_time_ms": 15,
"user_agent": "Mozilla/5.0 ...",
"cache_status": "HIT"
}
```
| Field | Type | Required | Source (OpenResty) | Business Meaning |
| --- | --- | --- | --- | --- |
| `logged_at_unix` | int64 | ✅ | parse `$time_iso8601` | request completion time |
| `remote_addr` | string | ✅ | `$remote_addr` | client IP → UV |
| `host` | string | ✅ | `$host` | domain → Zone ownership |
| `path` | string | ✅ | `$request_uri`, Agent may truncate | path |
| `status_code` | int | ✅ | `$status` | status code |
| `bytes_sent` | int64 | ✅ | **`$body_bytes_sent`** | **data provided** (response body) |
| `request_length` | int64 | suggested | `$request_length` | **data received** |
| `request_time_ms` | int64 | optional | `$request_time * 1000` | latency; default 0 |
| `user_agent` | string | suggested | `$http_user_agent` | UA; may truncate on store |
| `cache_status` | string | suggested | **`$upstream_cache_status`** | edge cache result (see §3.5.1) |
**Explicitly not reported by Agent (written by Server):**
* `region` / country: GeoIP resolved at insert time
* `id` / `created_at`: Server-generated
* `node_id`: from payload / auth context
**Explicitly not reported:**
* `upstream_addr` / origin address / `origin_fetched`: no origin-endpoint tracking; "did it fetch from origin" is only derived from `cache_status` at the control plane (§3.5.1)
### 3.5.1 `cache_status` — Cache Hit and Origin Fetch (detail-first)
**Goal (phase 1):** access log details/list can show "cache hit / origin fetch / no cache used".
**Caliber:** only store the raw OpenResty `$upstream_cache_status`; **no upstream address reported**.
#### Raw Values (stored)
| Value | Meaning (OpenResty) |
| --- | --- |
| `HIT` | cache hit |
| `MISS` | miss, fetched from upstream |
| `BYPASS` | cache skipped (e.g. method/cookie/policy caused `$openflare_skip_cache`) |
| `EXPIRED` | expired then origin fetch |
| `STALE` | served stale |
| `UPDATING` | background updating, may return old cache |
| `REVALIDATED` | revalidated, still used cache |
| `-` or empty | didn't pass through `proxy_cache` (e.g. Pages local static, non-proxy location) |
#### UI Three-State Derivation (not stored)
Control-plane display uses derived enum `cache_outcome`, **not written to CH**:
| Three-State | Condition (`cache_status`) | Suggested List Label |
| --- | --- | --- |
| **Cache hit** | `HIT` / `STALE` / `REVALIDATED` / `UPDATING` | hit |
| **Origin fetch** | `MISS` / `EXPIRED` | origin |
| **No cache used** | `BYPASS` / `-` / `""` | not cached |
Details can show both the three-state and the raw `cache_status`.
#### Boundaries
* Pages static / locations without `proxy_cache`: mostly empty or `-` → **no cache used**, must not be labeled "hit".
* Detail pages show cache state; hit-rate dashboards and hourly dimensions can extend from the same column.
**Per-heartbeat count suggestion:**
* Soft cap e.g. 2000 lines/beat; overflow goes into `buffered` next batch, **forbidden** to compress into a TrafficReport in the Agent.
### 3.6 `buffered[]` — Offline Backfill (facts only)
```json
{
"captured_at_unix": 1719999900,
"host_metrics": { },
"edge_health": { },
"access_logs": [ ]
}
```
| Field | Description |
| --- | --- |
| `captured_at_unix` | batch collection/buffer time, used for ack and dedup window |
| `host_metrics` / `edge_health` / `access_logs` | same structures as the main payload; empty blocks may be omitted |
**Forbidden** to carry `traffic_report` or rx/tx throughput in buffered.
### 3.7 `health_events[]`
```json
{
"event_type": "openresty_unhealthy",
"severity": "critical",
"message": "...",
"triggered_at_unix": 1720000000,
"metadata": { }
}
```
Written to the relational health-event table (existing model suffices), not into the access log lake.
### 3.8 Go Protocol Structures
```go
// pkg/protocol/agent.go (current implementation)
type NodePayload struct {
SchemaVersion int `json:"schema_version,omitempty"`
NodeID string `json:"node_id"`
Name string `json:"name"`
IP string `json:"ip"`
Version string `json:"version"`
ExtVersion string `json:"ext_version"`
CurrentVersion string `json:"current_version"`
LastError string `json:"last_error"`
OpenrestyStatus string `json:"openresty_status"` // PG latest-state authority
OpenrestyMessage string `json:"openresty_message"` // PG latest-state authority; not into CH
Profile *NodeSystemProfile `json:"profile,omitempty"`
HostMetrics *NodeHostMetrics `json:"host_metrics,omitempty"`
EdgeHealth *NodeEdgeHealth `json:"edge_health,omitempty"`
AccessLogs []NodeAccessLog `json:"access_logs,omitempty"`
Buffered []BufferedFacts `json:"buffered,omitempty"`
HealthEvents []NodeHealthEvent `json:"health_events"`
WAFIPGroupChecksums map[string]string `json:"waf_ip_group_checksums,omitempty"`
}
type NodeHostMetrics struct {
CapturedAtUnix int64 `json:"captured_at_unix"`
CPUUsagePercent float64 `json:"cpu_usage_percent"`
MemoryUsedBytes int64 `json:"memory_used_bytes"`
MemoryTotalBytes int64 `json:"memory_total_bytes"`
StorageUsedBytes int64 `json:"storage_used_bytes"`
StorageTotalBytes int64 `json:"storage_total_bytes"`
DiskReadBytes int64 `json:"disk_read_bytes"`
DiskWriteBytes int64 `json:"disk_write_bytes"`
NetworkRxBytes int64 `json:"network_rx_bytes"`
NetworkTxBytes int64 `json:"network_tx_bytes"`
}
type NodeEdgeHealth struct {
CapturedAtUnix int64 `json:"captured_at_unix"`
Status string `json:"status"`
Message string `json:"message"`
Connections int64 `json:"connections"`
}
type NodeAccessLog struct {
LoggedAtUnix int64 `json:"logged_at_unix"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Path string `json:"path"`
UserAgent string `json:"user_agent,omitempty"`
CacheStatus string `json:"cache_status,omitempty"` // $upstream_cache_status
StatusCode int `json:"status_code"`
BytesSent int64 `json:"bytes_sent"` // body_bytes_sent, data provided
RequestLength int64 `json:"request_length"` // data received
RequestTimeMs int64 `json:"request_time_ms"` // optional
}
type BufferedFacts struct {
CapturedAtUnix int64 `json:"captured_at_unix"`
HostMetrics *NodeHostMetrics `json:"host_metrics,omitempty"`
EdgeHealth *NodeEdgeHealth `json:"edge_health,omitempty"`
AccessLogs []NodeAccessLog `json:"access_logs,omitempty"`
}
```
---
## 4. Server Parsing and Storage Flow
### 4.1 Entry Points
* HTTP: `POST /api/v1/agent/...` heartbeat (existing path)
* WebSocket: `type=status` payload = `NodePayload`
* Auth: `X-Agent-Token` → binds `node_id` (payload.node_id must match the token node)
### 4.2 Processing Pipeline (single payload)
```text
1. Deserialize NodePayload
2. Normalize
- schema_version < 2:
host_metrics ← snapshot
edge_health.status ← openresty_status
edge_health.connections ← openresty_observation.connections (if present)
traffic_report → drop
openresty_observation.rx/tx → drop
buffered ← buffered_observability
- path truncated again, status clamped, negative bytes → 0
3. Relational transaction (node latest state)
- update node online time, IP, version, edge_health.status/message
- upsert profile (if present)
- insert health_events (if present)
4. ClickHouse async batch (log on failure; doesn't block heartbeat response config delivery)
a. access_logs + buffered[].access_logs
→ fill region (GeoIP)
→ assign snowflake id
→ BatchInsert of_node_access_logs
b. host_metrics + buffered[].host_metrics
→ of_node_metric_snapshots
c. edge_health + buffered[].edge_health
→ of_node_edge_health (connections + status snapshot only, optional)
5. Return heartbeat response (settings / active_config / waf diff)
6. If using buffer ack: confirm by the list of buffered.captured_at_unix
```
### 4.3 Normalization Rules (hard constraints)
| Rule | Behavior |
| --- | --- |
| `logged_at` ahead of now+5m | clamp to now or drop the line (pick one in implementation and unit-test it) |
| `logged_at` older than now−TTL | still writable; relies on table TTL cleanup |
| empty `host` | allowed, aggregated into "unassigned" |
| `bytes_sent` / `request_length` < 0 | set to 0 |
| single batch access_logs > N | truncate and alert-metric it (or only queue into buffer), never switch to pre-aggregation |
| duplicate backfill | CH tolerates a few duplicate rows; queries approximate with sum (no forced exact dedup) |
### 4.4 Field Mapping Table (report → table)
| Report Path | Target Storage | Columns |
| --- | --- | --- |
| `access_logs[]` | CH `of_node_access_logs` | see §5.1 |
| `host_metrics` | CH `of_node_metric_snapshots` | see §5.2 |
| `edge_health` | CH `of_node_edge_health` + PG node latest state | see §5.3 / §5.6 |
| `profile` | PG `of_node_system_profiles` | existing columns |
| `health_events` | PG health-event table | existing model |
| `waf_ip_group_checksums` | not into observability tables | sync logic |
| `traffic_report` (legacy) | **not written** | — |
| `openresty_rx/tx` (legacy) | **not written** | — |
### 4.5 Query Side (no new "business outbound" column)
| Product Metric | SQL Semantics (sketch) |
| --- | --- |
| Data provided | `sum(bytes_sent)` |
| Data received | `sum(request_length)` |
| Request count | `count()` |
| UV | `uniqExact(remote_addr)` |
| 5xx | `countIf(status_code >= 500)` |
| By domain/status/region | `GROUP BY host / status_code / region` |
| Host NIC outbound | non-negative delta over `network_tx_bytes` per node in time order, then sum |
| OpenResty connections | `of_node_edge_health.connections` latest or average |
---
## 5. Table Structures (DDL)
> Engine and TTL tend to match production: access logs 90 days, metrics 30 days.
> `id` uses control-plane Snowflake/unique UInt64.
### 5.1 L1 Fact Table: `of_node_access_logs`
```sql
CREATE TABLE IF NOT EXISTS of_node_access_logs
(
id UInt64,
node_id String,
logged_at DateTime64(3, 'UTC'),
remote_addr String,
region String, -- Server GeoIP writes, Agent doesn't send
host String,
path String,
user_agent String DEFAULT '', -- $http_user_agent
cache_status String DEFAULT '', -- $upstream_cache_status
status_code Int32,
bytes_sent UInt64, -- data provided (body)
request_length UInt64 DEFAULT 0, -- data received
request_time_ms UInt32 DEFAULT 0, -- optional
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(logged_at)
ORDER BY (node_id, logged_at, host, status_code, remote_addr)
TTL toDateTime(logged_at) + INTERVAL 90 DAY
SETTINGS index_granularity = 8192;
```
| Column | Type | Source |
| --- | --- | --- |
| `id` | UInt64 | Server |
| `node_id` | String | auth/payload |
| `logged_at` | DateTime64(3) | `logged_at_unix` |
| `remote_addr` | String | report |
| `region` | String | Server GeoIP |
| `host` | String | report |
| `path` | String | report |
| `user_agent` | String | report (nullable) |
| `cache_status` | String | report (nullable) → **cache status** |
| `status_code` | Int32 | report |
| `bytes_sent` | UInt64 | report → **data provided** |
| `request_length` | UInt64 | report → **data received** |
| `request_time_ms` | UInt32 | report optional |
| `created_at` | DateTime64(3) | Server now |
**Migration:** the current table already has `bytes_sent` / `request_length` / `request_time_ms` / `user_agent`; cache status adds:
```sql
ALTER TABLE of_node_access_logs
ADD COLUMN IF NOT EXISTS cache_status String DEFAULT '';
```
### 5.2 L1 Hourly Rollup (Server-side MV)
**Agent forbidden to write.** Serves dashboard/node 24h fast queries of request count, error count, bytes.
**Implemented choice: `SummingMergeTree` + no UV column.**
```sql
CREATE TABLE IF NOT EXISTS of_access_log_hourly
(
node_id String,
hour DateTime('UTC'),
host String,
request_count UInt64,
error_count UInt64,
bytes_sent UInt64,
request_length UInt64
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (node_id, hour, host)
TTL hour + INTERVAL 90 DAY;
CREATE MATERIALIZED VIEW IF NOT EXISTS of_access_log_hourly_mv
TO of_access_log_hourly
AS
SELECT
node_id,
toStartOfHour(logged_at) AS hour,
host,
toUInt64(count()) AS request_count,
toUInt64(countIf(status_code >= 500)) AS error_count,
sum(bytes_sent) AS bytes_sent,
sum(request_length) AS request_length
FROM of_node_access_logs
GROUP BY node_id, hour, host;
```
Historical hours (details stored before the MV existed) need a one-time backfill, see migration `202607180003_backfill_access_log_hourly.sql` (ANTI JOIN to prevent duplicates).
#### UV Policy (must follow)
| Scenario | Data Source | Algorithm | Notes |
| --- | --- | --- | --- |
| **Window total UV** (dashboard totals, node cards, Zone totals) | `of_node_access_logs` details | `uniqExact(remote_addr)` (`TrafficSummary` / node aggregation) | **single authority**; never sum hourly UV |
| **24h trend line request/error/bytes** | `of_access_log_hourly` preferred, fall back to detail buckets | `sum(request_count)` etc. | hourly path **doesn't fill** `unique_visitor_count` (always 0) |
| **24h trend per-hour UV** | detail bucket path only | in-bucket `uniqExact` | when using hourly, UI should show empty/0 or hide the UV series; **forbidden** to `sum(UV)` over hourly rows |
**Why hourly doesn't store UV:**
1. `SummingMergeTree` can only safely merge addable counts; `uniqExact` across parts needs `AggregatingMergeTree` + state, heavier to implement and query.
2. Even if hourly UV were stored, **summing over multi-hour windows severely overestimates** (the same IP is counted once per hour).
3. Product "24h unique visitors" only recognizes whole-window `uniqExact`; the trend chart's main series are requests/errors/bytes — per-hour UV is not a primary metric.
### 5.3 L3 Fact Table: `of_node_metric_snapshots` (kept, semantics clarified)
```sql
CREATE TABLE IF NOT EXISTS of_node_metric_snapshots
(
id UInt64,
node_id String,
captured_at DateTime64(3, 'UTC'),
cpu_usage_percent Float64,
memory_used_bytes Int64,
memory_total_bytes Int64,
storage_used_bytes Int64,
storage_total_bytes Int64,
disk_read_bytes Int64, -- cumulative raw
disk_write_bytes Int64,
network_rx_bytes Int64, -- cumulative raw → host NIC inbound
network_tx_bytes Int64, -- cumulative raw → host NIC outbound
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(captured_at)
ORDER BY (node_id, captured_at, id)
TTL toDateTime(captured_at) + INTERVAL 30 DAY
SETTINGS index_granularity = 8192;
```
Columns match production; **docs and API must label `network_*` as host NIC cumulative values**.
### 5.4 L3 Hourly Rollup: `of_node_metric_capacity_hourly` (kept)
Existing min/max used for cumulative-counter hourly increment approximation + CPU/memory averages. Logic unchanged:
* `network_tx_max - network_tx_min` ≈ that hour's host outbound
* **must not** be used for "data provided"
### 5.5 L2 Fact Table: `of_node_edge_health` (new, replaces throughput-style openresty table)
```sql
CREATE TABLE IF NOT EXISTS of_node_edge_health
(
id UInt64,
node_id String,
captured_at DateTime64(3, 'UTC'),
status LowCardinality(String), -- healthy / unhealthy / unknown
connections Int64,
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(captured_at)
ORDER BY (node_id, captured_at, id)
TTL toDateTime(captured_at) + INTERVAL 30 DAY
SETTINGS index_granularity = 8192;
```
| Column | Description |
| --- | --- |
| `status` | instant health (same source as PG current state; for time series, not the sole UI authority) |
| `connections` | current connection count |
**No** `message` column (description only in PG latest state).
**No** `openresty_rx_bytes` / `openresty_tx_bytes`.
### 5.6 Relational DB (node latest state, not an analytics lake)
Separate from the observability lake, keeping "latest one":
| Table (logical name) | Purpose | Key Columns |
| --- | --- | --- |
| `of_nodes` (or current node table) | online, version, IP | `last_seen_at`, `openresty_status`, `openresty_message`, `agent_version` |
| `of_node_system_profiles` | profile upsert | hostname, cpu_cores, total_memory_bytes, ... |
| health-event table | `health_events` | event_type, severity, message, triggered_at |
> Actual physical table names follow the repo's existing GORM models; this design doesn't force renames, only forces **business throughput no longer written into node tables**.
### 5.7 Deprecated Tables (stop writing → delete after TTL)
| Table | Reason | Replacement |
| --- | --- | --- |
| `of_node_request_reports` | Agent pre-aggregation | `of_node_access_logs` + hourly |
| `of_node_traffic_hourly` + MV | depends on request_reports | `of_access_log_hourly` |
| `of_node_obs_openresty` | contains business rx/tx | `of_node_edge_health` |
| `of_node_openresty_hourly` + MV | business throughput deltas | `of_access_log_hourly` bytes_* |
Relay-specific `of_node_obs_frps` / `of_node_obs_frpc` **kept** (not this Agent's main path, but same CH observability).
---
## 6. Table-Protocol Cross-Reference
| Product Concept | Protocol Field | Table.Column | Aggregation |
| --- | --- | --- | --- |
| Data provided | `access_logs[].bytes_sent` | `of_node_access_logs.bytes_sent` | `sum` |
| Data received | `access_logs[].request_length` | `...request_length` | `sum` |
| Request count | row count | — | `count` |
| UV (window total) | `remote_addr` | same details | `uniqExact` (**forbidden** to sum hourly UV) |
| Top domains | `host` | same | `group by` |
| Status distribution | `status_code` | same | `group by` |
| Source region | — | `region` (Server) | `group by` |
| Host NIC outbound | `host_metrics.network_tx_bytes` | `of_node_metric_snapshots.network_tx_bytes` | time-series delta |
| Host NIC inbound | `network_rx_bytes` | same | delta |
| Disk read/write | `disk_*_bytes` | same | delta |
| CPU/memory | instant fields | same | avg |
| OpenResty connections | `edge_health.connections` | `of_node_edge_health.connections` | latest/avg |
| OpenResty health | `edge_health.status` | node table + optional CH | latest |
**Mappings that no longer exist:**
| Old Concept | Old Field | Disposition |
| --- | --- | --- |
| OpenResty outbound | `openresty_tx_bytes` | removed; use data provided |
| OpenResty inbound | `openresty_rx_bytes` | removed; use data received |
| Window request report | `traffic_report` | removed |
---
## 7. OpenResty Log Format (aligned with details)
Target `log_format` (ensures the `bytes_sent` key = body; includes UA and cache status):
```nginx
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",'
'"cache_status":"$upstream_cache_status"}';
```
Agent parsing:
* `ts` → `logged_at_unix`
* `bytes_sent` → protocol `bytes_sent` (provided)
* `request_length` → protocol `request_length`
* `request_time` → optional `request_time_ms = round(sec * 1000)`
* `user_agent` → protocol `user_agent`
* `cache_status` → protocol `cache_status` (passed through as-is, no three-state compression)
---
## 8. Upgrade Strategy (no compatibility layer)
| Item | Strategy |
| --- | --- |
| Agent upgrade | **destroy-and-recreate** preferred; **binary replacement** allowed |
| Protocol | only schema v2 fields; legacy JSON fields not parsed |
| Local observability buffer | if still in old format (containing `snapshot` / `openresty_observation` / `traffic_report`) or corrupt → **delete the file wholesale**, rebuild at runtime |
| Read path | business APIs **only read** access_logs (and hourly); current health reads PG; connection series reads CH edge_health |
| Old Agents | must upgrade; the control plane provides no v1 dual-read path |
---
## 9. Example: Storage Result of One Heartbeat
**Agent report (excerpt):**
```json
{
"schema_version": 2,
"node_id": "n1",
"host_metrics": {
"captured_at_unix": 1720000000,
"cpu_usage_percent": 10,
"memory_used_bytes": 1,
"memory_total_bytes": 2,
"storage_used_bytes": 3,
"storage_total_bytes": 4,
"disk_read_bytes": 100,
"disk_write_bytes": 200,
"network_rx_bytes": 1000,
"network_tx_bytes": 2000
},
"edge_health": {
"captured_at_unix": 1720000000,
"status": "healthy",
"message": "",
"connections": 5
},
"access_logs": [
{
"logged_at_unix": 1720000001,
"remote_addr": "1.1.1.1",
"host": "a.example.com",
"path": "/",
"status_code": 200,
"bytes_sent": 500,
"request_length": 80
}
]
}
```
**Written:**
1. PG node latest state: `openresty_status` / `openresty_message` (if reported)
2. `of_node_metric_snapshots` 1 row (network_tx=2000 cumulative)
3. `of_node_edge_health` 1 row (status + connections=5; **no message**)
4. `of_node_access_logs` 1 row (bytes_sent=500, request_length=80, region filled by Server)
5. MV asynchronously counts into `of_access_log_hourly`
**Query 24h data provided:** `sum(bytes_sent)` → at least 500 (plus history)
**Query host outbound:** delta over snapshots; **no forced equality** with 500.
---
## 10. Revision History
| Date | Notes |
| --- | --- |
| 2026-07-17 | initial draft: protocol v2, Server storage pipeline, CH/relational target table structures and deprecated table list |