Core Concepts¶
Architecture¶
graph TB
subgraph "Your Applications"
A1[App 1<br/>+SDK]
A2[App 2<br/>+SDK]
A3[Test Suite<br/>+SDK]
end
subgraph "Zelos Agent"
B[gRPC Server]
C[In-Memory Buffer]
D[Stream Publisher]
end
subgraph "Consumers"
E[Zelos App]
F[Cloud]
end
A1 --> B
A2 --> B
A3 --> B
B --> C
C --> D
D --> E
D --> F
Data Flow¶
- Your code calls SDK logging functions
- SDK validates types and adds timestamps
- Agent stores in memory and broadcasts to UI
- Visualization displays real-time data
Data Model¶
Hierarchy¶
Source / Event . Field
└──┬──┘ └─┬──┘ └─┬──┘
│ │ │
│ │ │
│ │ └───────────── Data point within event
│ └───────────────────── Collection of related fields
└───────────────────────────── Logical component/subsystem
Example: motor_controller/status.rpm
└──────┬───────┘ └──┬──┘ └┬┘
│ │ └─── Field (RPM value)
│ └───────── Event (status event)
└────────────────────── Source (motor controller)
Sources¶
A source identifies a logical component or subsystem. Sources are lightweight - create as many as needed for organization.
# One source per component
motor = zelos_sdk.TraceSource("motor_controller")
battery = zelos_sdk.TraceSource("battery_management")
sensors = zelos_sdk.TraceSource("sensor_array")
Properties:
- Unique identifier (UUID) per instance
- Automatic segment start/end events
- Independent event schemas
- Python only:
TraceSource(name, strict=True)rejects a float logged to an integer field. By default the SDK converts it.
Events¶
An event groups related fields that are logged together. All fields in an event share the same timestamp, ensuring temporal correlation.
# All three values logged atomically with same timestamp
source.log("telemetry", {
"voltage": 48.2, # ┐
"current": 12.5, # ├── Same timestamp
"power": 602.5 # ┘
})
Properties:
- Named collection of fields
- Single timestamp for all fields
- Schema definition (optional but recommended)
- Support for nested events (submessages)
Fields¶
A field is an individual data point within an event. When combined with source and event names, it becomes a signal - a unique time series.
# Define field with type and unit
field = zelos_sdk.TraceEventFieldMetadata(
name="temperature",
data_type=zelos_sdk.DataType.Float64,
unit="°C" # Optional
)
Properties:
- Strong typing (14 data types)
- Optional units metadata
- Value tables for enums
- Becomes a signal when qualified with source/event
Names¶
Source, event and field names appear verbatim in signal paths, in the app and in query results. They follow one grammar:
- A name contains Unicode letters, Unicode digits, spaces,
_and-. - Event names can also contain
/. The/separates a nested event from its parent, as inmotor/thermal. - A name is not empty and is at most 128 bytes in UTF-8.
- A name has no leading or trailing space.
| Kind | Allowed characters | Example |
|---|---|---|
| Source | letters, digits, space, _, - |
motor_controller |
| Event | letters, digits, space, _, -, / |
motor/thermal |
| Field | letters, digits, space, _, - |
cell voltage |
Names in any script are valid: Größe and 温度センサ pass.
Punctuation such as ., :, % and [ does not.
The . is reserved because it separates the event from the field in a signal path.
The Python SDK checks a name when you register it.
TraceSource(...), add_event(...) and the first log(...) or log_batch(...) of a new event raise ValueError on an invalid name.
The message names the rule and the character:
ValueError: source name "bus:can0" contains ':' (U+003A), which is not allowed;
names may use letters, digits, spaces, '_', '-'; event names may use '/'
The Rust and Go SDKs do not check names, and the agent stores names as it receives them. Follow the same grammar there so that every signal path stays unambiguous.
Sanitize external names¶
Names from an external file, such as DBC signals or scope channel labels, often break the grammar.
Pass them through zelos_sdk.sanitize_name(name, *, kind="source") before you register them.
Set kind to "source", "event", "field" or "value_table".
import zelos_sdk
zelos_sdk.sanitize_name("bus:can0") # 'bus_can0'
zelos_sdk.sanitize_name("Engine.RPM[0]", kind="field") # 'Engine_RPM_0'
zelos_sdk.sanitize_name("battery/status", kind="event") # 'battery/status'
zelos_sdk.sanitize_name("battery/status", kind="field") # 'battery_status'
zelos_sdk.sanitize_name("...") # 'unnamed'
sanitize_name applies these steps:
- It replaces each character outside the grammar with
_. A run of such characters becomes one_. - It keeps
/only for the"event"and"value_table"kinds. - It trims spaces and
_from both ends. - It cuts the result to 128 bytes on a character boundary.
- It returns
"unnamed"when nothing is left.
The result always passes registration for that kind.
Sanitizing twice gives the same result as sanitizing once.
An unknown kind raises ValueError.
Note
sanitize_name is lossy. a.b, a..b and a:b all become a_b.
The trace treats two identical names as one signal, so remove duplicates before you register them.
Data Types¶
| Type | Bytes | Range | Use Case |
|---|---|---|---|
| int8 | 1 | -128 to 127 | Small signed values |
| int16 | 2 | -32,768 to 32,767 | Sensor readings |
| int32 | 4 | ±2.1 billion | Counters |
| int64 | 8 | ±9.2 quintillion | Timestamps |
| uint8 | 1 | 0 to 255 | Byte values, IDs |
| uint16 | 2 | 0 to 65,535 | Analog values |
| uint32 | 4 | 0 to 4.2 billion | Large counters |
| uint64 | 8 | 0 to 18 quintillion | Unique IDs |
| float32 | 4 | ±3.4E38 (7 digits) | Sensor data |
| float64 | 8 | ±1.7E308 (15 digits) | Precise measurements |
| bool | 1 | true/false | States, flags |
| string | Variable | UTF-8 text | Text, states |
| binary | Variable | Raw bytes | Payloads, images |
| timestamp_ns | 8 | Nanoseconds since epoch | Time points |
Timing¶
Timestamps¶
Every event gets a timestamp automatically:
# Automatic timestamp (current time)
source.log("data", {"value": 42})
# Explicit timestamp (nanoseconds since epoch)
source.log_at(1699564234567890123, "data", {"value": 42})
# Using time module
import time
timestamp_ns = time.time_ns()
source.log_at(timestamp_ns, "data", {"value": 42})
Resolution: Nanosecond (10⁻⁹ seconds) Epoch: Unix epoch (1970-01-01 00:00:00 UTC) Range: ±292 years from epoch
Segments¶
A segment represents the lifetime of a source, from creation to close. Creating a source sends a segment start. Closing it sends a segment end:
- Python: the source's namespace holds a reference to every source it registers. The segment ends when the namespace shuts down, which for the global namespace is interpreter exit.
- Rust: dropping the
TraceSourcesends the segment end. - Go: call
source.Close().
Each segment has its own event schemas. A new source with the same name starts a new segment, and it can register a different schema for the same event name.
Namespaces (Python)¶
A namespace connects sources to the outputs that read them. Each namespace has its own router. Sources send events into the router, and every subscribed output receives them.
The Global Namespace¶
Every source belongs to the global namespace unless you pass namespace=.
zelos_sdk.init() connects the global namespace to the agent.
A TraceWriter without namespace= records the global namespace.
Isolated Namespaces¶
Create a TraceNamespace to keep a group of sources apart from the global namespace.
Pass it as namespace= to TraceSource, TraceSourceCache and TraceWriter.
Events in an isolated namespace reach only the outputs attached to it.
import zelos_sdk
ns = zelos_sdk.TraceNamespace("bench")
source = zelos_sdk.TraceSource("power_supply", namespace=ns)
reading = source.add_event("reading", [
zelos_sdk.TraceEventFieldMetadata("voltage", zelos_sdk.DataType.Float64, "V"),
])
with zelos_sdk.TraceWriter("bench.trz", namespace=ns):
for i in range(1000):
reading.log(voltage=12.0 + i * 0.001)
ns.drain() # Deliver every queued event before the writer closes
The agent connection and TraceStdout always read the global namespace.
Events in an isolated namespace never reach the Zelos App.
Use an isolated namespace to:
- Record a file without streaming the same data to the app.
- Keep the data of separate tests, or of a file conversion, out of each other's recordings.
- Use the same source name in two recordings at once.
TraceNamespace has these members:
| Member | Description |
|---|---|
TraceNamespace(name) |
Creates an isolated namespace with its own router |
name |
The name passed to the constructor |
source_count() |
The number of sources registered with the namespace |
drain() |
Flushes every source and blocks until every queued event reaches the outputs |
Call drain() when you need every logged event to have reached its outputs before you go on, for example before you read a file that is still open.
Closing a TraceWriter flushes the namespace's sources on its own.
drain() can be called more than once.
Do not call it from an async Python task, because it blocks the calling thread.
Buffering & Batching¶
How It Works¶
The SDK batches events before it sends them:
- Python sources collect rows per event and pass them on in batches.
A background task passes on rows that wait too long.
Call
source.flush()to pass them on at once. -
The publish client sends a batch when:
-
The batch reaches
batch_sizemessages (default: 1000) batch_timeout_mspasses (default: 100)- Backpressure: in Python and Rust, logging calls wait when the agent cannot keep up.
In Go,
Emitreturns an error when the client's 1024-message channel is full.
Configuration¶
Network Protocol¶
- Transport: gRPC over HTTP/2
- Encoding: Protocol Buffers v3
- Stream: Bidirectional for publish, server-stream for subscribe
Schema Evolution¶
An event's fields are fixed when the event is registered, either by add_event() or by its first log() call.
- A log call can leave out fields. The missing fields are null for that row.
- A log call cannot add a field. In Python,
source.log()raisesNotImplementedError("Dynamic fields are not yet supported"), andevent.log()raisesValueError. add_event()with a name that the source already registered raisesValueError("Event=... already exists").
source.log("data", {"temperature": 25.0}) # Registers "data" with one field
source.log("data", {}) # OK: temperature is null
source.log("data", {"temperature": 25.0, "humidity": 60.0}) # NotImplementedError
To add a field, do one of these:
- Log the new field under a new event name, such as
data_v2. - Create a new source. Each source is a new segment with its own schemas, so the next run of your program can register the new field.
FAQ¶
How much data can I send?
The limit depends on your network bandwidth and the resources of the machine that runs the Agent. The SDK batches events to reduce per-message overhead.
What happens if the Agent is down?
The client retries the connection on its own. Python and Rust do not keep events that you log while the client is disconnected. When the connection returns, the client sends the source and event schemas again, so new events appear without a restart. Go keeps up to 1024 messages in its channel while it waits, and Emit returns an error when the channel is full.
Can I use multiple sources in one file?
Yes! Create as many sources as needed. They're lightweight and help organize your data.