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 IDprovider: The concrete provider instance (P) handling transport specificsdisabled: A boolean flag set whenLIGHTPANDA_DISABLE_TELEMETRYis detected in the environmentrun_mode: The currentConfig.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.Mutexandstd.Thread.Conditioncoordinate 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 identifiermode: The current run modeosandarch: Target platform metadataversion: 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:
- Compile-time provider swapping: Replace
LightPandawith any type implementinginit,deinit, andsendby changing theTelemetryTgeneric argument - Mock providers: Test suites use
MockProviderandFailingProvider(shown insrc/testing.zig) to record events in memory or simulate network failures - Environment flags:
LIGHTPANDA_DISABLE_TELEMETRYdisables 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_TELEMETRYenvironment variable provides runtime opt-out - The default
LightPandaprovider 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 insrc/telemetry/telemetry.zigandsrc/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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →