This document is written for a data / analytics team that needs to consume xtcp2's TCP telemetry into an enterprise data platform (lakehouse, warehouse, query engine). It assumes you're fluent in Parquet, object storage, and SQL, but only have a basic understanding of TCP — so it explains the columns that matter most and where to focus your first implementation.
The short version: when xtcp2 runs with the S3/Parquet destination it writes Hive-style partitioned, column-compressed Apache Parquet files to an S3-compatible bucket. One Parquet row = one socket observed at one poll. The schema is flat (no nested or repeated fields), one column per field, so it loads cleanly into any Parquet reader.
- Where the files land
- File size, cadence, and compression
- Reading the data
- Loading into Snowflake
- The grain: one row per socket per poll
- Start here: the columns that matter
- Decoding cheat sheet
- Full schema and column types
- Types, nulls, and gotchas
- Where the schema is defined
- See also
Object keys are Hive-partitioned by host, date, and hour (all UTC):
<prefix>/host=<hostname>/date=<YYYY-MM-DD>/hour=<HH>/<unix_ts>_<rand>.parquet
Example:
xtcp/host=web-01/date=2026-06-19/hour=14/1750345200_9f3a1c20.parquet
host=— the emitting machine (hostname); sanitized for object-store safety, empty →unknown.date=/hour=— UTC wall clock at write time, ready for partition pruning (WHERE date = '...' AND hour = '...').- The file name is
<unix-seconds>_<random-hex>.parquet— unique, append-only; xtcp2 never rewrites a file.
These partition keys are part of the path, not the file (standard Hive convention). Most engines (Spark, Trino/Athena, DuckDB, BigQuery external tables) expose host, date, hour as virtual columns automatically when you point them at <prefix>/.
- Size: xtcp2 finalizes and uploads a file when its in-memory builder reaches a soft cap of ~63 MiB uncompressed (configurable via
-s3ParquetFlushBytes). On the wire the.parquetis several times smaller after compression. A partial file is also flushed on shutdown, so the last file of a run may be small. - Cadence: depends on traffic volume — a busy host fills 63 MiB quickly; a quiet host may take a while, so don't assume one file per hour. Use the
date/hourpartitions, not file counts. - Compression: per-column. String and address columns use ZSTD (high ratio); numeric columns use SNAPPY (fast, widely supported). Every mainstream Parquet reader handles both — you don't need to configure anything.
Point any Parquet engine at the prefix. A few starting points:
-- DuckDB (great for exploration); hive_partitioning surfaces host/date/hour as columns
SELECT host, date, hour, count(*) AS rows
FROM read_parquet('s3://bucket/xtcp/**/*.parquet', hive_partitioning = true)
WHERE date = '2026-06-19'
GROUP BY 1,2,3 ORDER BY 1,2,3;# pandas / pyarrow
import pyarrow.dataset as ds
dataset = ds.dataset("s3://bucket/xtcp/", format="parquet", partitioning="hive")
df = dataset.to_table(columns=["timestamp_ns","hostname","tcp_info_rtt"]).to_pandas()-- Trino / Athena: create an external table over the prefix with
-- partitions (host string, date string, hour string); project columns you need.Always select only the columns you need — there are 122, and columnar pruning is where Parquet earns its keep. Likewise filter on date/hour for partition pruning.
If your platform team already manages an S3 storage integration, ingesting is a few statements. The column names match the Parquet schema, so name-based matching does the mapping for you.
-- One-time: a Parquet file format and an external stage over the prefix.
-- STORAGE_INTEGRATION is the bucket grant your platform team provisions.
CREATE OR REPLACE FILE FORMAT xtcp_parquet TYPE = PARQUET;
CREATE OR REPLACE STAGE xtcp_stage
URL = 's3://bucket/xtcp/'
STORAGE_INTEGRATION = my_s3_int
FILE_FORMAT = xtcp_parquet;
-- Auto-create a table whose columns mirror the Parquet schema.
CREATE OR REPLACE TABLE xtcp_flat_records
USING TEMPLATE (
SELECT ARRAY_AGG(OBJECT_CONSTRUCT(*))
FROM TABLE(INFER_SCHEMA(LOCATION => '@xtcp_stage', FILE_FORMAT => 'xtcp_parquet'))
);
-- Load. MATCH_BY_COLUMN_NAME maps Parquet columns → table columns by name.
COPY INTO xtcp_flat_records
FROM @xtcp_stage
FILE_FORMAT = (TYPE = PARQUET)
MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE;For continuous, query-in-place ingestion instead of a one-shot load, define an external table with AUTO_REFRESH = TRUE (backed by Snowpipe + an S3 event notification) over the same stage.
Two Snowflake-specific notes:
- Partitions live in the path, not the file. Snowflake won't surface
host/date/hourautomatically the way Spark/Trino do — derive them frommetadata$filename(e.g.REGEXP_SUBSTR(metadata$filename, 'date=([^/]+)', 1, 1, 'e', 1)) as virtual columns on an external table, or as extra columns duringCOPYvia a transform. They make great clustering/pruning keys. - The two IP-address columns load as
BINARY. Decode them withinet_diag_msg_familyas in the cheat sheet — or, if you'd rather not decode in SQL, point the daemon at the humanized CSV/JSON output instead (see output formats).
xtcp2 polls every network namespace on a fixed interval (default 10s) and emits one row per TCP socket per poll. So:
- A long-lived connection appears in many rows over time — one per poll while it exists.
- Most counters (bytes, segments, retransmits) are cumulative over the socket's lifetime, so you typically
MAX()them per socket or take deltas between consecutive polls. - Identify a single socket across polls with
inet_diag_msg_socket_cookie(a stable kernel-assigned id) together withhostname/netns.
If you're scoping an initial implementation, these are the high-value columns. Everything else can come later.
| Column | Type | Meaning |
|---|---|---|
timestamp_ns |
double | When the sample was taken — Unix epoch nanoseconds, UTC. Divide by 1e9 for seconds. |
hostname |
string | Emitting machine (also the host= partition). |
netns |
string | Network namespace path — distinguishes host vs container/pod sockets. |
inet_diag_msg_socket_cookie |
uint64 | Stable per-socket id; use to track one connection across polls. |
| Column | Type | Meaning |
|---|---|---|
inet_diag_msg_family |
uint32 | Address family: 2 = IPv4, 10 = IPv6. Tells you how to read the address bytes. |
inet_diag_msg_socket_source |
bytes | Local IP, raw bytes (4 for v4, 16 for v6). See decoding. |
inet_diag_msg_socket_source_port |
uint32 | Local port (host byte order; use as-is). |
inet_diag_msg_socket_destination |
bytes | Remote IP, raw bytes. |
inet_diag_msg_socket_destination_port |
uint32 | Remote port. |
inet_diag_msg_state |
uint32 | TCP state (see the state map); 1=ESTABLISHED, 10=LISTEN. |
| Column | Type | Unit / meaning |
|---|---|---|
tcp_info_rtt |
uint32 | Smoothed round-trip time, microseconds. The headline latency metric. |
tcp_info_min_rtt |
uint32 | Minimum RTT seen, microseconds — a cleaner latency baseline. |
tcp_info_rtt_var |
uint32 | RTT variance, microseconds (jitter). |
tcp_info_snd_cwnd |
uint32 | Congestion window, in packets/segments (not bytes). |
tcp_info_total_retrans |
uint32 | Cumulative retransmitted segments — the simplest "is this connection healthy?" signal. |
tcp_info_bytes_sent / tcp_info_bytes_acked |
uint64 | Cumulative bytes sent / acknowledged. |
tcp_info_bytes_received |
uint64 | Cumulative bytes received. |
tcp_info_delivery_rate |
uint64 | Recent delivery rate, bytes/second — effective throughput. |
tcp_info_pacing_rate |
uint64 | Sender pacing rate, bytes/second. |
congestion_algorithm_string |
string | Congestion-control algorithm name (e.g. cubic, bbr) — easiest to read. |
A solid first dashboard: per host/destination, MAX(tcp_info_rtt) and MAX(tcp_info_min_rtt), the delta of tcp_info_total_retrans, and throughput from tcp_info_delivery_rate — filtered to inet_diag_msg_state = 1 (ESTABLISHED).
A few columns are stored as machine values for fidelity/size and need decoding for humans:
-
IP addresses (
inet_diag_msg_socket_source/_destination) are raw bytes. Read them withinet_diag_msg_family: 4 bytes → dotted-quad IPv4, 16 bytes → IPv6. In DuckDB you can reconstruct IPv4 asconcat_ws('.', get_byte(col,0), get_byte(col,1), get_byte(col,2), get_byte(col,3)). If you'd rather not decode in SQL at all, the daemon can emit already humanized CSV/JSON instead — see output formats — but the Parquet path keeps raw bytes so nothing is lost. -
TCP state (
inet_diag_msg_state, andtcp_info_state) is a kernel integer. Map:value name value name 1 ESTABLISHED 7 CLOSE 2 SYN_SENT 8 CLOSE_WAIT 3 SYN_RECV 9 LAST_ACK 4 FIN_WAIT1 10 LISTEN 5 FIN_WAIT2 11 CLOSING 6 TIME_WAIT 12 NEW_SYN_RECV -
Congestion algorithm: prefer
congestion_algorithm_string(the kernel name). Thecongestion_algorithm_enuminteger is0=UNSPECIFIED,1=CUBIC,2=DCTCP,3=VEGAS,4=PRAGUE,5=BBR1,6=BBR2,7=BBR3. -
timestamp_ns is a double;
to_timestamp(timestamp_ns / 1e9)(or your engine's equivalent) gives a UTC timestamp.
The complete column list (122 columns) groups as follows; column names are the proto's snake_case names, identical to the ClickHouse table columns:
- Metadata —
timestamp_ns(double),hostname,netns,nsid,label,tag,record_counter,socket_fd,netlinker_id. inet_diag_msg_*— the socket id/4-tuple, state, queues, uid/inode, ASN annotations.mem_info_*/sk_mem_info_*— socket memory accounting.tcp_info_*— the bulk of the data: RTT, cwnd, ssthresh, MSS, windows, segment and byte counters, pacing/delivery rates, RTO stats, busy/limited times.congestion_algorithm_*— enum (int32) + string name.- Per-algorithm blocks —
vegas_info_*,dctcp_info_*,bbr_info_*(only meaningful when that algorithm is in use). - QoS / misc —
type_of_service,traffic_class,shutdown_state,class_id,sock_opt,c_group.
Column types are: double (timestamp only), string (hostname/netns/label/tag/congestion string), bytes (the two IP-address columns), int32 (congestion enum), and uint32/uint64 for everything else. The authoritative, field-by-field list with types and compression is the ParquetRow struct; field meanings are in the protobuf schema and protobuf-formats.md.
- No NULLs. The records come from proto3, which has no null — an absent/zero value is the numeric
0(or empty string/bytes). Treat0as "unset or genuinely zero"; don't expect SQLNULL. - Counters are cumulative, per socket lifetime — delta between consecutive polls (matched by
inet_diag_msg_socket_cookie) for per-interval rates, orMAX()for totals. - Units differ: RTTs are microseconds; rates are bytes/second;
snd_cwndis packets; byte counters are bytes. The per-column units are in the tables above. - Per-algorithm columns are sparse-in-meaning:
bbr_info_*is only populated when the socket uses BBR, etc. Filter oncongestion_algorithm_stringbefore trusting them. - Schema evolution: new fields are added (never renamed/reordered in place), so plan for forward-compatible reads (select by name, tolerate new columns).
The Parquet columns are generated from the ParquetRow struct, whose parquet: tags set the column names and per-column compression. A drift test (TestS3ParquetSchema_matchesProto) asserts that set matches the XtcpFlatRecord proto field-for-field, so the Parquet schema, the protobuf, and the ClickHouse table never diverge. To change the schema you edit the proto, regenerate (nix run .#regen-protos), and mirror the field in ParquetRow. The S3/Parquet destination itself is documented in output formats & destinations; it ships only in builds that include the dest_s3parquet tag (see build flavors).
- Socket analysis — finding RTT bands and other socket groupings by clustering, once the data is loaded.
- Protobuf formats — the canonical schema and field semantics.
- Output formats & destinations — the S3/Parquet destination and the alternative humanized CSV/JSON formats.
- Build flavors — enabling the
s3parquetdestination.