Skip to content

Repository files navigation

posthog-zig

civersionziglicense

A server-side PostHog analytics client for Zig. Non-blocking event capture with background batch delivery, retry, and graceful shutdown.

  • Zig: requires 0.16.0 or newer (tested on the 0.16.x line).
  • 0.15.2 users: pin posthog-zig 0.1.x — see docs/v1/ZIG_0_15_COMPAT.md.
  • PostHog API:/batch/ (capture) + /decide/ v3 (feature flags).

What is here

FeatureAPINotes
Event captureclient.capture()Non-blocking — enqueues to ring buffer, returns immediately
User identificationclient.identify()Non-blocking
Group analyticsclient.group()Non-blocking — workspace / org level traits
Error trackingclient.captureException()Non-blocking — emits PostHog $exception format for Error Tracking UI
Batch deliverybackground threadFlushes on timer (default 10s) or queue threshold (default 20 events)
Retryexponential backoffbase 1s, max 30s, jitter, 3 attempts; drops after max retries
Graceful shutdownclient.deinit()Drains remaining queue with configurable timeout (default 5s)
Feature flagsclient.isFeatureEnabled()Calls /decide/ v3, caches per distinct_id with 60s TTL
Feature flag payloadsclient.getFeatureFlagPayload()Same cache as above
Manual flushclient.flush()Synchronous — blocks until current queue is delivered

Delivery guarantees

Shutdown pathOutcome
SIGTERMclient.deinit()Queue drained, events delivered
SIGKILLQueue lost — no delivery
Zig panic (unhandled)Queue lost — no delivery
OOM during flushRetry 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.


Install

zig fetch --save https://github.com/usezombie/posthog-zig/archive/refs/tags/<tag>.tar.gz

build.zig:

constposthog=b.dependency("posthog", .{ .target=target, .optimize=optimize });
exe.root_module.addImport("posthog", posthog.module("posthog"));

Usage

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();

Minimal integration (any Zig daemon)

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);
}

Capturing errors without panicking

// 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;
}

Optional client pattern

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.


Configuration

OptionDefaultDescription
api_keyrequiredPostHog project API key (phc_...)
hosthttps://us.i.posthog.comPostHog ingestion host
enable_loggingtrueEnable SDK logs (warn/err/info/debug)
flush_interval_ms10_000How often the flush thread wakes (ms)
flush_at20Flush when this many events are queued
max_queue_size1000Queue capacity; drops newest on overflow
max_retries3Max delivery attempts per batch
shutdown_flush_timeout_ms5_000Reserved for timed join support in a future release; currently deinit() blocks until the flush thread joins
feature_flag_ttl_ms60_000Feature flag cache TTL per distinct_id

Building and testing

# 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 memleak

License

MIT — see LICENSE.

Built for usezombie.

About

posthog zig sdk

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages