Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 31 additions & 15 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ name: Release

on:
push:
tags:
- "v*"
branches:
- main

permissions:
contents: read
Expand All @@ -22,15 +22,20 @@ jobs:
# ── Verify VERSION matches git tag ────────────────────────────────────────
verify-version:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.meta.outputs.tag }}
version: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v6
- name: Check VERSION matches tag
- name: Resolve release version
id: meta
run: |
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
FILE_VERSION="$(cat VERSION | tr -d '[:space:]')"
echo "tag=$TAG_VERSION file=$FILE_VERSION"
[ "$TAG_VERSION" = "$FILE_VERSION" ] || { echo "✗ VERSION ($FILE_VERSION) does not match tag ($TAG_VERSION)"; exit 1; }
echo "✓ VERSION matches tag"
VERSION="$(tr -d '[:space:]' < VERSION)"
echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || { echo "✗ VERSION must be semver (x.y.z), got: $VERSION"; exit 1; }
TAG="v$VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "✓ release version resolved: $VERSION ($TAG)"

# ── GitHub Release with CHANGELOG excerpt ────────────────────────────────
create-release:
Expand All @@ -43,32 +48,43 @@ jobs:

- name: Extract changelog for this version
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
VERSION="${{ needs.verify-version.outputs.version }}"
sed -n "/^## \[$VERSION\]/,/^## \[/p" CHANGELOG.md | sed '$d' > release_notes.txt
echo "--- Release notes ---"
cat release_notes.txt

- name: Create GitHub Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.verify-version.outputs.tag }}
target_commitish: ${{ github.sha }}
body_path: release_notes.txt
name: "posthog-zig v${{ github.ref_name }}"
name: "posthog-zig ${{ needs.verify-version.outputs.tag }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

# ── Publish zig package (zig fetch verification) ──────────────────────────
verify-fetchable:
runs-on: ubuntu-latest
needs: create-release
needs: [create-release, verify-version]
steps:
- uses: actions/checkout@v6
- uses: mlugg/setup-zig@v2
with:
version: 0.15.2
- name: Create temp fetch workspace
run: |
FETCH_DIR="$(mktemp -d /tmp/posthog-fetch-XXXXXX)"
echo "FETCH_DIR=$FETCH_DIR" >> "$GITHUB_ENV"
- name: Verify package is fetchable
working-directory: ${{ env.FETCH_DIR }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
mkdir /tmp/fetch-test && cd /tmp/fetch-test
TAG="${{ needs.verify-version.outputs.tag }}"
cat > build.zig << EOF
const std = @import("std");

pub fn build(_: *std.Build) void {}
EOF
cat > build.zig.zon << EOF
.{
.name = .@"fetch-test",
Expand All @@ -81,5 +97,5 @@ jobs:
.paths = .{ "build.zig.zon" },
}
EOF
zig fetch --save https://github.com/usezombie/posthog-zig/archive/refs/tags/$TAG.tar.gz
zig fetch --save "https://github.com/usezombie/posthog-zig/archive/refs/tags/$TAG.tar.gz"
echo "✓ package fetchable via zig fetch"
156 changes: 156 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,162 @@ Design decisions, tradeoffs, and the target evolution of posthog-zig.

---

## End-to-end flow: from usezombie to PostHog

```mermaid
flowchart TD
subgraph zombied["zombied (usezombie control plane)"]
cap["capture()\nrun_started, deploy, run_end\n+ custom properties"]
exc["captureException()\n$exception_type, $exception_message\n$exception_level, stack_trace (opt)"]
ident["identify() — $set traits"]
grp["group() — $group_type / $group_key"]
end

cap & exc & ident & grp --> ser["Serialize to JSON (< 1μs)\ndistinct_id, $lib, $lib_version, ISO 8601 timestamp"]

subgraph sdk["posthog-zig SDK"]
subgraph queue["Queue — batch.zig"]
enq["enqueue()\nmutex lock → arena dupe → append → unlock"]
arenaA["Arena A (write side)\n[ev1][ev2][ev3]..."]
drop["count ≥ 1000? → drop newest"]
signal["count ≥ 20? → signal cond var"]
end

subgraph flush["FlushThread — flush.zig"]
wait["timedWait() — OS parks thread (zero CPU)"]
wake["Wake on: signal (≥20) | timeout (10s) | shutdown"]
drain["drain() — swap write↔flush sides, O(1)"]
arenaB["Arena B (flush side) — owned exclusively"]
post["POST /batch/"]
ok["2xx → on_deliver(.delivered)"]
retryable["429/5xx → retry, backoff min(1s×2^n, 30s)+jitter, max 3"]
bad["4xx → on_deliver(.failed), drop"]
exhausted["Retries exhausted → on_deliver(.dropped)"]
reset["resetSide() — one arena reset, all memory reclaimed"]
end
end

ser --> enq
enq --> arenaA
enq --> drop
enq --> signal
signal --> wake
wait --> wake
wake --> drain
drain --> arenaB
arenaB --> post
post --> ok
post --> retryable
post --> bad
retryable --> exhausted
ok & bad & exhausted --> reset

post --> posthog

subgraph posthog["PostHog (us.i.posthog.com/batch/)"]
events["Events — custom events"]
errors["Error Tracking — $exception"]
persons["Persons — $identify"]
groups["Groups — $groupidentify"]
end
```

<details><summary>ASCII version (for terminals / non-Mermaid renderers)</summary>

```
┌──────────────────────────────────────────────────────────────────────────────┐
│ zombied (usezombie control plane) │
│ │
│ ┌─────────────────────┐ ┌──────────────────────┐ ┌──────────────────┐ │
│ │ capture("run_start") │ │ captureException() │ │ identify() │ │
│ │ capture("run_end") │ │ $exception_type │ │ group() │ │
│ │ capture("deploy") │ │ $exception_message │ │ │ │
│ │ + custom properties │ │ $exception_level │ │ $set traits │ │
│ │ │ │ stack_trace (opt) │ │ $group_type/key │ │
│ └──────────┬───────────┘ └──────────┬───────────┘ └────────┬─────────┘ │
│ │ │ │ │
│ └─────────────────┬───────┘───────────────────────┘ │
│ ▼ │
│ serialize to JSON (< 1μs) │
│ + distinct_id, $lib, $lib_version, ISO 8601 timestamp │
└──────────────────────────────┬─────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ posthog-zig SDK │
│ │
│ ┌─────────────────────────── Queue (batch.zig) ───────────────────────┐ │
│ │ │ │
│ │ enqueue() — mutex lock, arena dupe, append to event index, unlock │ │
│ │ If count >= flush_at (default 20): signal condition variable │ │
│ │ If count >= max_queue_size (default 1000): drop event (newest) │ │
│ │ │ │
│ │ Arena A (write side) Arena B (flush side) │ │
│ │ [ev1_json][ev2_json][ev3]... [being POSTed] │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────── FlushThread (flush.zig) ──────────────────────┐ │
│ │ │ │
│ │ loop: │ │
│ │ wait for signal OR flush_interval_ms timeout (default 10s) │ │
│ │ drain() — swap write↔flush sides (O(1) index flip under mutex) │ │
│ │ if no events: continue │ │
│ │ POST /batch/ with all events from flush side │ │
│ │ on 2xx: on_deliver(.delivered) → resetSide() (arena reclaim) │ │
│ │ on 429/5xx: retry up to max_retries (default 3) │ │
│ │ exponential backoff: min(1s × 2^attempt, 30s) + jitter │ │
│ │ on 4xx: on_deliver(.failed) → drop batch, no retry │ │
│ │ retries exhausted: on_deliver(.dropped) → drop batch │ │
│ │ resetSide() — one arena reset, all memory reclaimed │ │
│ │ │ │
│ │ on shutdown: │ │
│ │ final drain() + POST (best-effort, no retry on manual flush) │ │
│ │ thread.join() │ │
│ │ │ │
│ └────────────────────────────┬───────────────────────────────────────┘ │
│ │ │
└───────────────────────────────┼────────────────────────────────────────────┘
▼ HTTP POST
┌──────────────────────────────────────────────────────────────────────────────┐
│ PostHog (https://us.i.posthog.com/batch/) │
│ │
│ { "api_key": "phc_...", "batch": [ {event}, {event}, ... ] } │
│ │
│ Events appear in: │
│ • Events → custom events (capture) │
│ • Error Tracking → $exception events (captureException) │
│ • Persons → $identify events (identify) │
│ • Groups → $groupidentify events (group) │
└──────────────────────────────────────────────────────────────────────────────┘
```

</details>

### What gets pushed

| Method | PostHog event | Use case in zombied |
|---|---|---|
| `capture()` | Custom event name (e.g. `run_started`, `deploy_completed`) | Business analytics, usage tracking |
| `captureException()` | `$exception` with `$exception_type`, `$exception_message`, `$exception_level`, optional stack trace | Error tracking — caught errors, OOM, workspace failures |
| `identify()` | `$identify` with `$set` properties | Associate traits (email, plan) with a distinct_id |
| `group()` | `$groupidentify` with `$group_type`, `$group_key`, `$group_set` | Associate users with workspaces/orgs |

### Key timing defaults

| Parameter | Default | Purpose |
|---|---|---|
| `flush_interval_ms` | 10,000 (10s) | Max wait before background flush fires |
| `flush_at` | 20 events | Threshold to wake flush thread early |
| `max_queue_size` | 1,000 events | Per-side capacity; overflow drops newest |
| `max_retries` | 3 | Retry count for 429/5xx responses |
| `shutdown_flush_timeout_ms` | 5,000 (5s) | Reserved for v0.2 timed join |
| `feature_flag_ttl_ms` | 60,000 (60s) | Cache TTL for feature flag decisions |

---

## Why this SDK exists

posthog-zig is the server-side analytics layer for the usezombie stack. The first
Expand Down