Lightpanda Telemetry System Architecture: Modular Event Tracking in Zig

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:

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:

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:

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:

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:

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:

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:

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:

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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →