Protocol reference
This page is the full contract for writing directly to the agent's local socket. Read it before you interpolate any name or value you did not write by hand.
The public contract is intentionally small:
- Discover the socket from
/etc/constellation/socket.env. - Connect to
CONSTELLATION_SOCKET_PATHwithAF_UNIX/SOCK_STREAM. - Write one metric per line, terminated by
\n. - Put the required topology key in the
entity_idtag. - Treat a completed socket write as local handoff, not remote-delivery acknowledgement.
Everything else is an implementation detail of the installed agent.
Discovery
The installer writes a world-readable env file containing only the socket location:
. /etc/constellation/socket.env
echo "$CONSTELLATION_SOCKET_PATH"
The default value is:
/run/constellation-agent/telemetry.sock
The socket path is retained across agent upgrades. Even so, read the env file rather than hardcoding the path — the documented default is a fallback, and /etc/constellation/socket.env is the machine-readable source of truth.
The API token lives in a separate file (/etc/constellation/agent.env, file mode 0600) that remains root-readable only. Publishing applications never read it.
Connection
Connect with the Unix domain socket family and a stream socket type:
socket(AF_UNIX, SOCK_STREAM)
connect(CONSTELLATION_SOCKET_PATH)
Access is granted by membership in the constellation group. The runtime directory is group-traversable and the socket is group-writable; the directory is created with mode 2770 owned by telegraf:constellation.
Group membership does not take effect in an existing session. After usermod -aG constellation <user>, the application must get a new login or a service restart.
Framing
One metric per line. Lines are separated by a single newline (\n):
measurement,tag_key=tag_value field_key=field_value timestamp_ns\n
There is no length prefix, no envelope, and no response frame. The agent reads newline-delimited records off the stream.
The four positional parts are:
| Part | Required | Separator | Notes |
|---|---|---|---|
| Measurement | yes | — | Must be non-empty. |
| Tags | no (but entity_id is) | leading ,, pairs joined by , | Always strings. |
| Fields | yes | one space after tags, pairs joined by , | At least one required. |
| Timestamp | no | one space after fields | Nanoseconds since the Unix epoch. |
Note the whitespace rule: a single unescaped space separates the tag section from the field section, and another separates fields from the timestamp. Unescaped spaces anywhere else change the parse.
Tags
Tags carry identity. They are always strings and are never type-annotated.
entity_idis required and must be non-empty. It joins the metric to an asset in Constellation.- Other tags are optional and application-defined (
site,band,source, and so on).
Use tags for stable identity and grouping, and fields for measured values. Do not put sample timestamps, request IDs, or changing counters in tags — every distinct tag value creates a distinct series.
The platform rejects telemetry without a non-empty entity_id. Validate it in your own code to fail before the network boundary:
if not tags.get("entity_id"):
raise ValueError("entity_id is required and must be non-empty")
Field types
The syntax of the value determines the stored type:
| Type | Syntax | Example |
|---|---|---|
| Float | bare number | snr=12.7 |
| Signed 64-bit integer | number with an i suffix | packets=4820i |
| Boolean | true or false | locked=true |
| String | double-quoted | modcod="16APSK-3/4" |
Rules that follow from those types:
- Floats must be finite.
NaN,Infinity, and-Infinityare not representable. - Integers must fit in signed 64 bits; anything larger must be sent as a float or a string.
1and0are floats, not Booleans. Writetrue/falsefor Booleans.- An unquoted word that is not
true/falseand not numeric is a parse error, not a string.
String fields are relocated into the tags object on reads instead of staying in fields.
Write the values as documented above and normalize on read until this is fixed platform-side.
Escaping
Escaping differs by position. This is the part applications most often get wrong.
| Position | Characters that must be escaped | Escape form |
|---|---|---|
| Measurement name | comma, space | \, \ |
| Tag key, tag value, field key | comma, equals, space | \, \= \ |
| String field value | double quote, backslash | \" \\ |
Notes:
- Do not escape the double quote in a measurement name, tag, or field key — it is not special there.
- Do not escape the equals sign in a string field value — it is not special there.
- The
=between a key and its value, and the,between pairs, are structural. Only escape those characters when they appear inside a name or value.
A newline can never appear anywhere in a line, escaped or not — it is the record separator. Reject or strip newlines in your own code before encoding.
Worked example. To send a site of Primary, Site and a note containing a quote:
ground_station,entity_id=GS-001,site=Primary\,\ Site note="said \"locked\"" 1785528000000000000
The tag value escapes both the comma and the space; the string field escapes only the inner quotes.
constellation-agent emit performs all of this encoding for you, which is why it is the right tool for shell automation and smoke tests.
Timestamps
The trailing field is nanoseconds since the Unix epoch (1970-01-01T00:00:00Z).
| Language | Expression |
|---|---|
| Shell | date +%s%N |
| Python | time.time_ns() |
| Go | time.Now().UnixNano() |
| Node.js | BigInt(Date.now()) * 1000000n |
| Rust | SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() |
Omit the timestamp entirely to let the agent stamp arrival time. That is the correct choice whenever agent arrival is an acceptable observation time, and it is safer than sending a timestamp from an unsynchronized device clock.
Use --timestamp-ns with emit when you need to record an observed-at time that differs from now.
Common failure: sending seconds or milliseconds where nanoseconds are expected. The magnitudes are unmistakable once you look at them:
1785528000 seconds (wrong — reads as 1970)
1785528000000 milliseconds (wrong)
1785528000000000000 nanoseconds (correct — 19 digits at present)
Connection reuse
Keep one connection open and write many lines to it. Do not open a connection per sample, and do not shell out to constellation-agent emit per sample.
# Correct: one connection, many writes
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.connect(SOCKET_PATH)
for sample in samples:
client.sendall(encode(sample).encode("utf-8"))
# Wrong: connection churn per sample
for sample in samples:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.connect(SOCKET_PATH)
client.sendall(encode(sample).encode("utf-8"))
The agent restarts on upgrade and on systemctl restart telegraf. A long-lived publisher must handle its connection being closed underneath it: catch the write error, reconnect with backoff, and continue. Buffer in your own process while reconnecting if the samples matter.
You may batch several newline-terminated metrics into a single write. That reduces syscalls and does not change semantics — the agent still parses one record per line.
Limits
Defaults set by the installer:
| Limit | Default | Set by |
|---|---|---|
| Flush interval | 1s | CONSTELLATION_FLUSH_INTERVAL |
| Metric buffer limit | 100,000 metrics | agent config |
| Metric batch size | 500 metrics | agent config |
| Buffer strategy | disk | CONSTELLATION_BUFFER_STRATEGY |
| Egress | grpc | CONSTELLATION_EGRESS |
Throughput and latency consequences:
- A record reaches
GET /topologyroughlyflush_intervalafter the socket write, plus a fixed transit/ingest floor: measured ~0.3s at a200msflush interval and ~0.6s at the1sdefault. LowerCONSTELLATION_FLUSH_INTERVALfor latency-sensitive fleets; raise it for high-volume ones. The cost is request rate per node. - The default
diskbuffer strategy survives an agent restart but fsyncs per metric, which caps sustained ingest near 290 metrics/s on NVMe and lower on eMMC or SD storage. - The
memorystrategy is substantially faster but loses up to oneflush_intervalof telemetry if the node dies.
If you need more than a few hundred metrics per second per node, set CONSTELLATION_BUFFER_STRATEGY=memory at install time and accept the restart-loss window.
Delivery semantics
A successful connect plus complete write means only that the local kernel accepted the bytes while the agent socket was present.
It does not prove that:
- the agent parsed the line;
- the metric entered the durable queue;
- the remote API accepted it;
- the metric is visible in topology.
Malformed input can still be discarded after your write succeeds. There is no acknowledgement, no error frame, and no durable receipt on the socket connection.
The local socket does not provide an at-least-once delivery guarantee. Verify records through topology when you need delivery confirmation.
End-to-end confirmation is the job of sudo constellation-agent verify and agent observability — see Operations.
Complete encoder example
A minimal, correct encoder in the Python standard library:
import socket
import time
SOCKET_PATH = "/run/constellation-agent/telemetry.sock"
def _escape(value, *, kind):
"""kind: 'measurement' | 'key' | 'string_field'"""
if "\n" in value:
raise ValueError("newlines cannot appear in line protocol")
if kind == "string_field":
return value.replace("\\", "\\\\").replace('"', '\\"')
out = value.replace(",", "\\,").replace(" ", "\\ ")
if kind == "key":
out = out.replace("=", "\\=")
return out
def encode(measurement, tags, fields, timestamp_ns=None):
if not tags.get("entity_id"):
raise ValueError("entity_id is required and must be non-empty")
if not fields:
raise ValueError("at least one field is required")
head = _escape(measurement, kind="measurement")
for key, value in tags.items():
head += f",{_escape(key, kind='key')}={_escape(str(value), kind='key')}"
parts = []
for key, value in fields.items():
key = _escape(key, kind="key")
if isinstance(value, bool): # before int — bool is an int
parts.append(f"{key}={str(value).lower()}")
elif isinstance(value, int):
if not -(2**63) <= value < 2**63:
raise ValueError(f"{key}: integer overflows signed 64 bits")
parts.append(f"{key}={value}i")
elif isinstance(value, float):
if value != value or value in (float("inf"), float("-inf")):
raise ValueError(f"{key}: non-finite floats are not representable")
parts.append(f"{key}={value}")
else:
parts.append(f'{key}="{_escape(str(value), kind="string_field")}"')
ts = time.time_ns() if timestamp_ns is None else timestamp_ns
return f"{head} {','.join(parts)} {ts}\n"
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.connect(SOCKET_PATH)
client.sendall(
encode(
"ground_station",
{"entity_id": "GS-001", "site": "primary"},
{"snr": 12.7, "locked": True},
).encode("utf-8")
)
The isinstance(value, bool) check must precede the int check: in Python, bool is a subclass of int, so testing int first would encode True as 1i.