# Lightpanda Telemetry System Architecture: Modular Event Tracking in Zig

> Explore the modular Lightpanda telemetry system architecture. Discover how the Zig event pipeline uses a facade pattern for flexible, compile-time provider swapping and runtime disabling.

- Repository: [Lightpanda/browser](https://github.com/lightpanda-io/browser)
- Tags: architecture
- Published: 2026-03-14

---

**Lightpanda’s telemetry system is a generic, pluggable event pipeline that uses a façade pattern to separate event generation from transport, enabling compile-time provider swapping and runtime disabling via environment variables.**

The `lightpanda-io/browser` repository implements a privacy-focused telemetry subsystem written in Zig that collects anonymous usage metrics while maintaining strict separation between data capture and transmission logic. This architecture allows the browser to ship production telemetry to `https://telemetry.lightpanda.io` while automatically substituting no-op implementations for debug builds and test suites.

## Core Telemetry Abstraction in `src/telemetry/telemetry.zig`

The entry point is the `Telemetry` type, a **generic wrapper** instantiated at compile time based on the build mode. In `src/telemetry/telemetry.zig`, the façade selects either a no-op provider for debug/test builds or the production `LightPanda` HTTP provider:

```zig
pub const Telemetry = TelemetryT(blk: {
    if (builtin.mode == .Debug or builtin.is_test) break :blk NoopProvider;
    break :blk @import("lightpanda.zig").LightPanda;
});

```

### Generic Wrapper Pattern

`TelemetryT` is a **comptime generic struct** (`comptime P: type`) that abstracts provider details while managing global state. It stores four critical fields:

- **`iid`**: An optional UUID array (`?[36]u8`) cached from disk representing the persistent install ID
- **`provider`**: The concrete provider instance (`P`) handling transport specifics  
- **`disabled`**: A boolean flag set when `LIGHTPANDA_DISABLE_TELEMETRY` is detected in the environment
- **`run_mode`**: The current `Config.RunMode` (serve, test, etc.) included in every event payload

The `init` function in `src/telemetry/telemetry.zig` orchestrates subsystem startup:

```zig
pub fn init(app: *App, run_mode: Config.RunMode) !Self {
    const disabled = isDisabled();
    const provider = try P.init(app);
    errdefer provider.deinit();

    return .{
        .disabled = disabled,
        .run_mode = run_mode,
        .provider = provider,
        .iid = if (disabled) null else getOrCreateId(app.app_dir_path),
    };
}

```

**Environment-based disabling** occurs through `isDisabled()`, which checks `std.process.hasEnvVarConstant("LIGHTPANDA_DISABLE_TELEMETRY")` at runtime, allowing users to opt out without recompilation.

### Install ID Persistence

The `getOrCreateId` function manages the **Install ID (iid)**—a UUIDv4 stored in a file named `iid` within the application data directory (`app_dir_path`). If the file is missing or the directory is inaccessible, the system generates a fresh UUID and persists it for subsequent runs, enabling consistent device-level tracking across sessions while respecting user privacy.

### Event Recording Interface

Components emit telemetry through the unified `record` method:

```zig
pub fn record(self: *Self, event: Event) void {
    if (self.disabled) return;
    const iid: ?[]const u8 = if (self.iid) |*iid| iid else null;
    self.provider.send(iid, self.run_mode, event) catch |err| {
        log.warn(.telemetry, "record error", .{ .err = err, ... });
    };
}

```

The façade validates the disabled state, extracts the optional install ID, and delegates to the provider's `send` method, converting transport errors into warnings without crashing the browser.

The **`Event` union** defines the telemetry schema as a lightweight, serializable structure:

```zig
pub const Event = union(enum) {
    run: void,
    navigate: Navigate,
    flag: []const u8,

    const Navigate = struct {
        tls: bool,
        proxy: bool,
        driver: []const u8 = "cdp",
    };
};

```

## Provider Implementation and HTTP Transport

The default **`LightPanda` provider** in `src/telemetry/lightpanda.zig` implements asynchronous, batched event transmission via HTTPS POST to the telemetry endpoint.

### Worker Thread Architecture

The provider initializes a **dedicated background thread** during the first `send` call, ensuring the main thread never blocks on network I/O:

```zig
pub fn send(self: *LightPanda, iid: ?[]const u8, run_mode: Config.RunMode, raw_event: telemetry.Event) !void {
    const event = try self.mem_pool.create();
    event.* = .{ .iid = iid, .mode = run_mode, .event = raw_event, .node = .{} };
    
    self.mutex.lock();
    defer self.mutex.unlock();
    
    if (self.thread == null) {
        self.thread = try std.Thread.spawn(.{}, run, .{self});
    }
    self.pending.append(&event.node);
    self.cond.signal();
}

```

Key implementation details include:
- **Memory pooling**: `std.heap.MemoryPool(LightPandaEvent)` eliminates allocation churn during high-frequency event recording
- **Synchronization**: A `std.Thread.Mutex` and `std.Thread.Condition` coordinate the producer-consumer queue
- **Lazy initialization**: The worker thread spawns only when the first event arrives, reducing startup overhead

### Batching and JSON Serialization

The worker thread (`run` function) implements **bounded batching** with a maximum batch size of 20 events (`MAX_BATCH_SIZE`). It drains the pending queue into a stack array, releases the lock, and serializes the batch:

```zig
fn run(self: *LightPanda) void {
    var batch: [MAX_BATCH_SIZE]*LightPandaEvent = undefined;
    self.mutex.lock();
    while (true) {
        while (self.pending.first != null) {
            const b = self.collectBatch(&batch);
            self.mutex.unlock();
            self.postEvent(b, &aw) catch |err| { log.warn(...); };
            self.mutex.lock();
        }
        if (!self.running) return;
        self.cond.wait(&self.mutex);
    }
}

```

The `postEvent` method serializes each `LightPandaEvent` using a custom **`jsonStringify`** implementation that emits newline-delimited JSON containing:
- `iid`: The install identifier
- `mode`: The current run mode
- `os` and `arch`: Target platform metadata
- `version`: Git commit hash
- Event-specific payload via union introspection

## Application Integration in `src/App.zig`

The telemetry subsystem is constructed during `App.init` in `src/App.zig` after the network and platform subsystems are ready:

```zig
app.telemetry = try Telemetry.init(app, config.mode);

```

The `App` struct owns the `telemetry: Telemetry` field, making it globally accessible to browser components. During shutdown, `App.deinit` explicitly invokes `self.telemetry.deinit()` to flush pending events and terminate the worker thread gracefully.

Any component can emit telemetry by accessing the app instance:

```zig
self.app.telemetry.record(.{ .navigate = .{ .tls = true, .proxy = false } });

```

## Extensibility and Testing

The architecture supports three extension mechanisms:

1. **Compile-time provider swapping**: Replace `LightPanda` with any type implementing `init`, `deinit`, and `send` by changing the `TelemetryT` generic argument
2. **Mock providers**: Test suites use `MockProvider` and `FailingProvider` (shown in `src/testing.zig`) to record events in memory or simulate network failures
3. **Environment flags**: `LIGHTPANDA_DISABLE_TELEMETRY` disables telemetry at runtime without code changes

## Summary

- **Lightpanda's telemetry architecture** uses a generic façade (`TelemetryT`) to decouple event generation from transport mechanisms
- The system automatically selects between **no-op providers** (debug builds) and **HTTP providers** (production) at compile time
- **Install IDs** persist as UUIDv4 in the application directory, while the `LIGHTPANDA_DISABLE_TELEMETRY` environment variable provides runtime opt-out
- The default `LightPanda` provider batches up to 20 events in a **background worker thread**, serializing them as newline-delimited JSON over HTTPS
- All integration points are centralized in `src/App.zig`, with the core logic residing in `src/telemetry/telemetry.zig` and `src/telemetry/lightpanda.zig`

## Frequently Asked Questions

### How do I disable Lightpanda telemetry without modifying the source code?

Set the environment variable `LIGHTPANDA_DISABLE_TELEMETRY` before launching the browser. The `isDisabled()` function in `src/telemetry/telemetry.zig` checks for this variable at startup and sets the internal `disabled` flag, causing all `record` calls to return immediately without invoking the provider.

### What is the maximum number of events batched before transmission?

The `LightPanda` provider batches a maximum of **20 events** per HTTP request, defined by the `MAX_BATCH_SIZE` constant in `src/telemetry/lightpanda.zig`. Once the worker thread collects 20 pending events or the queue empties, it releases the mutex and posts the batch asynchronously.

### Where is the install ID stored on disk?

The install ID (iid) is stored in a file named `iid` inside the application data directory (`app_dir_path`). The `getOrCreateId` function in `src/telemetry/telemetry.zig` handles reading this file or generating a fresh UUIDv4 if the file is missing, ensuring consistent tracking across browser sessions.

### Can I implement a custom telemetry provider for internal logging?

Yes. Implement a struct with `init(app: *App) !Self`, `deinit(self: *Self) void`, and `send(self: *Self, iid: ?[]const u8, mode: Config.RunMode, event: telemetry.Event) !void` methods, then instantiate `TelemetryT(MyProvider)` instead of the default. The generic design in `src/telemetry/telemetry.zig` accepts any provider conforming to this interface, enabling file-based logging, third-party analytics, or test mocks.