Skip to content

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

  1. Your code calls SDK logging functions
  2. SDK validates types and adds timestamps
  3. Agent stores in memory and broadcasts to UI
  4. 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 in motor/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:

  1. It replaces each character outside the grammar with _. A run of such characters becomes one _.
  2. It keeps / only for the "event" and "value_table" kinds.
  3. It trims spaces and _ from both ends.
  4. It cuts the result to 128 bytes on a character boundary.
  5. 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 TraceSource sends 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:

Events → Buffer → Batch → Network
         ↑                    ↓
         └──── Backpressure ←─┘
  1. 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.
  2. The publish client sends a batch when:

  3. The batch reaches batch_size messages (default: 1000)

  4. batch_timeout_ms passes (default: 100)
  5. Backpressure: in Python and Rust, logging calls wait when the agent cannot keep up. In Go, Emit returns an error when the client's 1024-message channel is full.

Configuration

config = zelos_sdk.TracePublishClientConfig(
    batch_size=1000,       # Events per batch
    batch_timeout_ms=100,  # Milliseconds
)
zelos_sdk.init(client_config=config)
let config = TracePublishClientConfig {
    batch_size: 1000,
    batch_timeout: Duration::from_millis(100),
    ..Default::default()
};
config := zelos.DefaultTracePublishClientConfig()
config.BatchSize = 1000
config.BatchTimeout = 100 * time.Millisecond

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() raises NotImplementedError ("Dynamic fields are not yet supported"), and event.log() raises ValueError.
  • add_event() with a name that the source already registered raises ValueError ("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.

Next Steps