A server-side PostHog analytics client for Zig. Non-blocking event capture with background batch delivery, retry, and graceful shutdown.
Zig: 0.15.x
PostHog API: /batch/ (capture) + /decide/ v3 (feature flags)
| Feature | API | Notes |
|---|---|---|
| Event capture | client.capture() |
Non-blocking — enqueues to ring buffer, returns immediately |
| User identification | client.identify() |
Non-blocking |
| Group analytics | client.group() |
Non-blocking — workspace / org level traits |
| Error tracking | client.captureException() |
Non-blocking — emits PostHog $exception format for Error Tracking UI |
| Batch delivery | background thread | Flushes on timer (default 10s) or queue threshold (default 20 events) |
| Retry | exponential backoff | base 1s, max 30s, jitter, 3 attempts; drops after max retries |
| Graceful shutdown | client.deinit() |
Drains remaining queue with configurable timeout (default 5s) |
| Feature flags | client.isFeatureEnabled() |
Calls /decide/ v3, caches per distinct_id with 60s TTL |
| Feature flag payloads | client.getFeatureFlagPayload() |
Same cache as above |
| Manual flush | client.flush() |
Synchronous — blocks until current queue is delivered |
| Shutdown path | Outcome |
|---|---|
SIGTERM → client.deinit() |
Queue drained, events delivered |
SIGKILL |
Queue lost — no delivery |
| Zig panic (unhandled) | Queue lost — no delivery |
| OOM during flush | Retry up to max_retries, then drop |
Delivery is best-effort for crash scenarios. For handled application errors
(for example, a caught error.NotFound or a failed DB query), the process is
healthy and the queue/flush path remains reliable.
Upcoming release will add crash-safe delivery: captureException with level == .fatal
will write a crash file to disk synchronously (no allocator, one write() syscall),
delivered on next startup. See docs/ARCHITECTURE.md for
the full design.
zig fetch --save https://github.com/usezombie/posthog-zig/archive/refs/tags/<tag>.tar.gzbuild.zig:
const posthog = b.dependency("posthog", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("posthog", posthog.module("posthog"));const posthog = @import("posthog");
// Init — heap-allocates client, spawns background flush thread.
// Heap allocation ensures &client.queue is a stable address for the flush thread.
const client = try posthog.init(allocator, .{
.api_key = "phc_...",
.host = "https://us.i.posthog.com", // default
.enable_logging = true, // default
.flush_interval_ms = 10_000, // default
.flush_at = 20, // flush when N events queued
.max_queue_size = 1000, // drop newest if exceeded
.max_retries = 3, // default
.shutdown_flush_timeout_ms = 5_000, // default
});
defer client.deinit(); // drains remaining events before exit
// Capture — non-blocking
try client.capture(.{
.distinct_id = "user_clerk_id",
.event = "run_started",
.properties = &.{
.{ .key = "workspace_id", .value = .{ .string = "ws_abc" } },
.{ .key = "spec_count", .value = .{ .integer = 3 } },
},
});
// Identify — non-blocking
try client.identify(.{
.distinct_id = "user_clerk_id",
.properties = &.{
.{ .key = "email", .value = .{ .string = "alice@example.com" } },
.{ .key = "plan", .value = .{ .string = "pro" } },
},
});
// Group — non-blocking
try client.group(.{
.distinct_id = "user_clerk_id",
.group_type = "workspace",
.group_key = "ws_abc",
.properties = &.{
.{ .key = "name", .value = .{ .string = "Acme Corp" } },
},
});
// Error tracking — non-blocking
// Shows up in PostHog → Error Tracking UI with full user context
try client.captureException(.{
.distinct_id = "user_clerk_id",
.exception_type = "WorkspaceError",
.exception_message = "workspace not found: ws_abc",
.handled = false,
.level = .err,
.properties = &.{
.{ .key = "workspace_id", .value = .{ .string = "ws_abc" } },
.{ .key = "run_id", .value = .{ .string = "run_xyz" } },
},
});
// Feature flags — sync, cached (one HTTP call per distinct_id per TTL)
const enabled = try client.isFeatureEnabled("new-dashboard", "user_clerk_id");
const payload = try client.getFeatureFlagPayload("new-dashboard", "user_clerk_id");
defer if (payload) |p| allocator.free(p); // caller owns the returned slice
// Manual flush — blocks until queue is empty
try client.flush();posthog-zig is a library. It cannot install a panic handler. The calling application owns that responsibility.
// src/main.zig
const posthog = @import("posthog");
// Hold the client at application scope so the panic hook can reach it.
var ph_client: ?*posthog.PostHogClient = null;
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Init — spawns background flush thread.
// Pass null api_key to disable analytics (e.g. when env var is absent).
if (std.posix.getenv("POSTHOG_API_KEY")) |key| {
ph_client = try posthog.init(allocator, .{ .api_key = key });
}
defer if (ph_client) |c| c.deinit(); // deinit frees the heap-allocated client // drains queue on SIGTERM / clean exit
// ... rest of your service
}
// Zig calls this on unhandled panics.
// Keep it minimal — the allocator may be corrupted.
pub fn panic(msg: []const u8, trace: ?*std.builtin.StackTrace, ret_addr: ?usize) noreturn {
// Current behavior: best-effort. If the flush thread is still alive it may deliver
// events already in the queue. Do not attempt to enqueue new events here —
// the allocator state is unknown.
// Upcoming release: ph_client.writeCrashFile() will be safe here (zero allocation,
// single write() syscall of the arena buffer). Not implemented yet.
std.debug.defaultPanic(msg, trace, ret_addr);
}// In any request handler or worker:
fn handleRun(client: *posthog.PostHogClient, user_id: []const u8) !void {
const result = runSpec() catch |err| {
// Handled error — process is healthy, queue path is safe.
client.captureException(.{
.distinct_id = user_id,
.exception_type = @errorName(err),
.exception_message = "spec execution failed",
.handled = true,
.level = .err,
}) catch {}; // never let analytics fail the request
return err;
};
_ = result;
}posthog-zig is designed to be optional in production — pass null when no API key
is configured. Wrap calls at the callsite:
if (ctx.posthog) |*ph| {
ph.capture(.{ .distinct_id = user_id, .event = "run_started" }) catch {};
}The catch {} is intentional: analytics must never propagate errors to the caller.
For deeper design rationale — memory model, crash delivery tradeoffs, and serialization approach — see docs/ARCHITECTURE.md.
| Option | Default | Description |
|---|---|---|
api_key |
required | PostHog project API key (phc_...) |
host |
https://us.i.posthog.com |
PostHog ingestion host |
enable_logging |
true |
Enable SDK logs (warn/err/info/debug) |
flush_interval_ms |
10_000 |
How often the flush thread wakes (ms) |
flush_at |
20 |
Flush when this many events are queued |
max_queue_size |
1000 |
Queue capacity; drops newest on overflow |
max_retries |
3 |
Max delivery attempts per batch |
shutdown_flush_timeout_ms |
5_000 |
Reserved for timed join support in a future release; currently deinit() blocks until the flush thread joins |
feature_flag_ttl_ms |
60_000 |
Feature flag cache TTL per distinct_id |
# Build
cd ~/Projects/posthog-zig && zig build
# Unit tests
zig build test
# Integration tests (requires PostHog API key)
POSTHOG_API_KEY=phc_... zig build test -Dintegration=true
# Verify no external C dependencies
zig build -Dtarget=x86_64-linux --summary all 2>&1 | grep "link with" && echo "WARN: C deps" || echo "PASS: pure Zig"
# Benchmark capture() hot path
zig build bench
# Coverage report (requires kcov: brew install kcov / apt-get install kcov)
make coverage
# Memory leak gate (valgrind on Linux, leaks on macOS)
make memleakMIT — see LICENSE.
Built for usezombie. Used in zombied (Zig control plane daemon) for production analytics.