Skip to content

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; implement against the fields and DDL in this document.

First read the transport overview and examples: Observability Transport Model.


1. Design Goals

GoalDescription
Agent reports only factsdetails + host readings + edge health instant state; no business pre-aggregation
One business detail tableaccess logs are the only L1 write path
Aggregation in DB/control planehourly summaries come from ClickHouse MV or queries; the Agent never writes summary tables
No field overlapbytes_sent = data provided; NIC network_* = host; no business openresty_tx anymore
Evolvablenew 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)
LayerMeaningAgent Report BlockClickHouse Fact Table
L1business deliveryaccess_logsof_node_access_logs
L2edge healthedge_healthof_node_edge_health
L3host capacityhost_metricsof_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..." }
}
FieldTypeRequiredDescription
schema_versionintsuggestedfixed to 2 (this design)
node_idstringnode ID
namestringdisplay name
ipstringreporting IP
version / ext_versionstringAgent version
current_versionstringlocally active config version summary
last_errorstringlatest sync/runtime error, nullable
openresty_statusstring✅ (when OpenResty present)latest health-state authoritative field → written to PG node table
openresty_messagestringlatest health-description authoritative field → written to PG node table (not into CH)
profileobjecthost overview, report on change (may throttle)
host_metricsobjectsuggested each beatL3 resource snapshot
edge_healthobjectsuggested each beatL2 connection time series + status aligned with top level
access_logsarraythis beat's incremental access details
bufferedarrayoffline backfill fact batches (see §3.6)
health_eventsarrayedge health events
waf_ip_group_checksumsmapfor differential sync, not an observability lake

Removed, Server no longer parses (no compatibility layer):

Old FieldDisposition
traffic_reportnot in the protocol; not stored
openresty_observationnot present; connections/status go through edge_health
snapshotnot present; only host_metrics
buffered_observabilitynot 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
}
FieldSemantics
hardware/OS description fieldsfactual readings
reported_at_unixAgent 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
}
FieldTypeSemanticsHow Server Uses It
captured_at_unixint64sampling timecaptured_at
cpu_usage_percentfloatinstant CPU%store directly; average for trends
memory_* / storage_*int64current used/totalstore directly; compute usage rate
disk_read_bytes / disk_write_bytesint64cumulative IO bytesstore raw; adjacent deltas at query time
network_rx_bytes / network_tx_bytesint64cumulative NIC bytesstore 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
}
FieldTypeSemantics
statusstringhealthy / unhealthy / unknown (must match top-level openresty_status)
messagestringstatus description (may be reported; only backfills PG latest state, not into CH)
connectionsint64stub_status Active connections

Health-State Authoritative Sources (converged)

DataAuthoritative StorageDescription
Current OpenResty health + descriptionPG node table openresty_status / openresty_messageUI badges, lists, alerts use this
Time series health status + connectionsCH of_node_edge_health (status, connections)connection curves / health history; no message column
Agent reporttop-level status/message + edge_healthServer 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"
}
FieldTypeRequiredSource (OpenResty)Business Meaning
logged_at_unixint64parse $time_iso8601request completion time
remote_addrstring$remote_addrclient IP → UV
hoststring$hostdomain → Zone ownership
pathstring$request_uri, Agent may truncatepath
status_codeint$statusstatus code
bytes_sentint64$body_bytes_sentdata provided (response body)
request_lengthint64suggested$request_lengthdata received
request_time_msint64optional$request_time * 1000latency; default 0
user_agentstringsuggested$http_user_agentUA; may truncate on store
cache_statusstringsuggested$upstream_cache_statusedge 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)

ValueMeaning (OpenResty)
HITcache hit
MISSmiss, fetched from upstream
BYPASScache skipped (e.g. method/cookie/policy caused $openflare_skip_cache)
EXPIREDexpired then origin fetch
STALEserved stale
UPDATINGbackground updating, may return old cache
REVALIDATEDrevalidated, still used cache
- or emptydidn'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-StateCondition (cache_status)Suggested List Label
Cache hitHIT / STALE / REVALIDATED / UPDATINGhit
Origin fetchMISS / EXPIREDorigin
No cache usedBYPASS / - / ""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": [ ]
}
FieldDescription
captured_at_unixbatch collection/buffer time, used for ack and dedup window
host_metrics / edge_health / access_logssame 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)

RuleBehavior
logged_at ahead of now+5mclamp to now or drop the line (pick one in implementation and unit-test it)
logged_at older than now−TTLstill writable; relies on table TTL cleanup
empty hostallowed, aggregated into "unassigned"
bytes_sent / request_length < 0set to 0
single batch access_logs > Ntruncate and alert-metric it (or only queue into buffer), never switch to pre-aggregation
duplicate backfillCH tolerates a few duplicate rows; queries approximate with sum (no forced exact dedup)

4.4 Field Mapping Table (report → table)

Report PathTarget StorageColumns
access_logs[]CH of_node_access_logssee §5.1
host_metricsCH of_node_metric_snapshotssee §5.2
edge_healthCH of_node_edge_health + PG node latest statesee §5.3 / §5.6
profilePG of_node_system_profilesexisting columns
health_eventsPG health-event tableexisting model
waf_ip_group_checksumsnot into observability tablessync logic
traffic_report (legacy)not written
openresty_rx/tx (legacy)not written

4.5 Query Side (no new "business outbound" column)

Product MetricSQL Semantics (sketch)
Data providedsum(bytes_sent)
Data receivedsum(request_length)
Request countcount()
UVuniqExact(remote_addr)
5xxcountIf(status_code >= 500)
By domain/status/regionGROUP BY host / status_code / region
Host NIC outboundnon-negative delta over network_tx_bytes per node in time order, then sum
OpenResty connectionsof_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;
ColumnTypeSource
idUInt64Server
node_idStringauth/payload
logged_atDateTime64(3)logged_at_unix
remote_addrStringreport
regionStringServer GeoIP
hostStringreport
pathStringreport
user_agentStringreport (nullable)
cache_statusStringreport (nullable) → cache status
status_codeInt32report
bytes_sentUInt64report → data provided
request_lengthUInt64report → data received
request_time_msUInt32report optional
created_atDateTime64(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)

ScenarioData SourceAlgorithmNotes
Window total UV (dashboard totals, node cards, Zone totals)of_node_access_logs detailsuniqExact(remote_addr) (TrafficSummary / node aggregation)single authority; never sum hourly UV
24h trend line request/error/bytesof_access_log_hourly preferred, fall back to detail bucketssum(request_count) etc.hourly path doesn't fill unique_visitor_count (always 0)
24h trend per-hour UVdetail bucket path onlyin-bucket uniqExactwhen 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;
ColumnDescription
statusinstant health (same source as PG current state; for time series, not the sole UI authority)
connectionscurrent 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)PurposeKey Columns
of_nodes (or current node table)online, version, IPlast_seen_at, openresty_status, openresty_message, agent_version
of_node_system_profilesprofile upserthostname, cpu_cores, total_memory_bytes, ...
health-event tablehealth_eventsevent_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)

TableReasonReplacement
of_node_request_reportsAgent pre-aggregationof_node_access_logs + hourly
of_node_traffic_hourly + MVdepends on request_reportsof_access_log_hourly
of_node_obs_openrestycontains business rx/txof_node_edge_health
of_node_openresty_hourly + MVbusiness throughput deltasof_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 ConceptProtocol FieldTable.ColumnAggregation
Data providedaccess_logs[].bytes_sentof_node_access_logs.bytes_sentsum
Data receivedaccess_logs[].request_length...request_lengthsum
Request countrow countcount
UV (window total)remote_addrsame detailsuniqExact (forbidden to sum hourly UV)
Top domainshostsamegroup by
Status distributionstatus_codesamegroup by
Source regionregion (Server)group by
Host NIC outboundhost_metrics.network_tx_bytesof_node_metric_snapshots.network_tx_bytestime-series delta
Host NIC inboundnetwork_rx_bytessamedelta
Disk read/writedisk_*_bytessamedelta
CPU/memoryinstant fieldssameavg
OpenResty connectionsedge_health.connectionsof_node_edge_health.connectionslatest/avg
OpenResty healthedge_health.statusnode table + optional CHlatest

Mappings that no longer exist:

Old ConceptOld FieldDisposition
OpenResty outboundopenresty_tx_bytesremoved; use data provided
OpenResty inboundopenresty_rx_bytesremoved; use data received
Window request reporttraffic_reportremoved

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:

  • tslogged_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)

ItemStrategy
Agent upgradedestroy-and-recreate preferred; binary replacement allowed
Protocolonly schema v2 fields; legacy JSON fields not parsed
Local observability bufferif still in old format (containing snapshot / openresty_observation / traffic_report) or corrupt → delete the file wholesale, rebuild at runtime
Read pathbusiness APIs only read access_logs (and hourly); current health reads PG; connection series reads CH edge_health
Old Agentsmust 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

DateNotes
2026-07-17initial draft: protocol v2, Server storage pipeline, CH/relational target table structures and deprecated table list

Released under the Apache License 2.0