A server-side PostHog analytics client for Zig. Non-blocking event capture with background batch delivery, retry, and graceful shutdown.
- Zig: requires
0.16.0or newer (tested on the 0.16.x line). - 0.15.2 users: pin posthog-zig
0.1.x— seedocs/v1/ZIG_0_15_COMPAT.md. - 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:
constposthog=b.dependency("posthog", .{ .target=target, .optimize=optimize });
exe.root_module.addImport("posthog", posthog.module("posthog"));constposthog=@import("posthog");
// Init — heap-allocates client, spawns background flush thread.// Heap allocation ensures &client.queue is a stable address for the flush thread.//// Zig 0.16 threads `std.Io` through every concurrency primitive. Pass// `posthog.defaultIo()` to use the process-wide Io, or your own Io.Threaded// instance if you want control over concurrency policy.constclient=tryposthog.init(allocator, posthog.defaultIo(), .{
.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
});
deferclient.deinit(); // drains remaining events before exit// Capture — non-blockingtryclient.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-blockingtryclient.identify(.{
.distinct_id="user_clerk_id",
.properties= &.{
.{ .key="email", .value= .{ .string="alice@example.com" } },
.{ .key="plan", .value= .{ .string="pro" } },
},
});
// Group — non-blockingtryclient.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 contexttryclient.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)constenabled=tryclient.isFeatureEnabled("new-dashboard", "user_clerk_id");
constpayload=tryclient.getFeatureFlagPayload("new-dashboard", "user_clerk_id");
deferif (payload) |p|allocator.free(p); // caller owns the returned slice// Manual flush — blocks until queue is emptytryclient.flush();posthog-zig is a library; it cannot install a panic handler. The calling application owns that responsibility.
// src/main.zigconststd=@import("std");
constposthog=@import("posthog");
// Hold the client at application scope so the panic hook can reach it.varph_client: ?*posthog.PostHogClient=null;
// 0.16: main receives an `Init` with the process allocator, Io, and environ.// `std.posix.getenv` and free-floating GPAs are gone — read env from// `init.environ_map` and pass `init.gpa` / `init.io` into library init.pubfnmain(init: std.process.Init) !void {
if (init.environ_map.get("POSTHOG_API_KEY")) |key| {
ph_client=tryposthog.init(init.gpa, init.io, .{ .api_key=key });
}
deferif (ph_client) |c|c.deinit(); // drains queue on SIGTERM / clean exit// ... rest of your service
}
// Zig calls this on unhandled panics.// Keep it minimal — the allocator may be corrupted.pubfnpanic(msg: []constu8, 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:fnhandleRun(client: *posthog.PostHogClient, user_id: []constu8) !void {
constresult=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 requestreturnerr;
};
_=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 |
# Buildcd~/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.