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
74 changes: 74 additions & 0 deletions .github/workflows/bench-regression.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: bench-regression

on:
pull_request:

permissions:
contents: read
pull-requests: write

jobs:
bench:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Install Zig
run: |
curl -L https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz -o zig.tar.xz
tar -xf zig.tar.xz
echo "$PWD/zig-x86_64-linux-0.15.2" >> "$GITHUB_PATH"

- name: Benchmark head
run: python3 scripts/run-bench-json.py bench-head.json

- name: Benchmark base
run: |
git fetch origin "${{ github.event.pull_request.base.ref }}" --depth=1
git worktree add ../codedb-base FETCH_HEAD
cd ../codedb-base
if python3 "$GITHUB_WORKSPACE/scripts/run-bench-json.py" "$GITHUB_WORKSPACE/bench-base.json"; then
echo "have_base_json=true" >> "$GITHUB_ENV"
else
echo "have_base_json=false" >> "$GITHUB_ENV"
fi

- name: Compare
if: env.have_base_json == 'true'
run: |
python3 scripts/compare-bench.py bench-base.json bench-head.json --threshold-pct 10 --markdown-out bench-report.md

- name: Bootstrap report
if: env.have_base_json != 'true'
run: |
cat > bench-report.md <<'EOF'
## Benchmark Regression Report

Skipped strict comparison because the base branch does not yet emit machine-readable benchmark JSON.
This PR introduces the JSON benchmark format that future PRs will compare against.
EOF

- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: bench-results
path: |
bench-base.json
bench-head.json
bench-report.md

- name: Comment PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('bench-report.md', 'utf8');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,14 +292,19 @@ All threads share a `shutdown: atomic.Value(bool)` for graceful termination.

## 🔒 Data & Privacy

codedb is **fully local** — no telemetry, no analytics, no network calls. Nothing leaves your machine.
codedb keeps runtime data local by default. Telemetry, when enabled, is written to `~/.codedb/telemetry.ndjson` on the same machine and is not uploaded automatically.

| Location | Contents | Purpose |
|----------|----------|---------|
| `~/.codedb/projects/<hash>/` | Trigram index, frequency table, data log | Persistent index cache |
| `~/.codedb/telemetry.ndjson` | Aggregate tool calls and startup stats | Local telemetry log |
| `./codedb.snapshot` | File tree, outlines, content, frequency table | Portable snapshot for instant MCP startup |

**Not stored:** No source code is sent anywhere. No network requests. No usage analytics. Sensitive files auto-excluded (`.env*`, `credentials.json`, `secrets.*`, `.pem`, `.key`, SSH keys, AWS configs).
**Not stored:** No source code is sent anywhere. No file contents, file paths, or search queries are collected in telemetry. Sensitive files auto-excluded (`.env*`, `credentials.json`, `secrets.*`, `.pem`, `.key`, SSH keys, AWS configs).

To disable the local telemetry log entirely, set `CODEDB_NO_TELEMETRY=1`.

To sync the local NDJSON file into Postgres for analysis or dashboards, use [`scripts/sync-telemetry.py`](./scripts/sync-telemetry.py) with the schema in [`docs/telemetry/postgres-schema.sql`](./docs/telemetry/postgres-schema.sql). The data flow is documented in [`docs/telemetry.md`](./docs/telemetry.md).

```bash
rm -rf ~/.codedb/ # clear all cached indexes
Expand Down
3 changes: 2 additions & 1 deletion build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ pub fn build(b: *std.Build) void {
}),
});


// ── mcp-zig dependency ──
const mcp_dep = b.dependency("mcp_zig", .{});
exe.root_module.addImport("mcp", mcp_dep.module("mcp"));
Expand Down Expand Up @@ -82,6 +81,8 @@ pub fn build(b: *std.Build) void {
}),
});
const bench_run = b.addRunArtifact(bench);
bench.root_module.addImport("mcp", mcp_dep.module("mcp"));
if (b.args) |args| bench_run.addArgs(args);
const bench_step = b.step("bench", "Run benchmarks");
bench_step.dependOn(&bench_run.step);
// Make module available so dependents don't need to wire it up manually
Expand Down
31 changes: 31 additions & 0 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Telemetry Data Flow

codedb writes local telemetry to `~/.codedb/telemetry.ndjson` unless `CODEDB_NO_TELEMETRY=1` is set. The file is append-only and stays on disk until an operator syncs it.

The current on-disk format is compact:

- `ts` or `timestamp_ms`
- `ev` or `event_type`
- `tool`, `ns` / `latency_ns`, `err` / `error`, `bytes` / `response_bytes`
- `files` / `file_count`, `lines` / `total_lines`
- optional `languages`, `index_size_bytes`, `startup_time_ms`, `version`, `platform`

`scripts/sync-telemetry.py` normalizes those fields and loads them into Postgres with `COPY`.

## Postgres schema

Use [`docs/telemetry/postgres-schema.sql`](./telemetry/postgres-schema.sql) to create the destination table and indexes.

## Sync

```bash
python3 scripts/sync-telemetry.py --dsn "$DATABASE_URL"
```

For a preview without touching Postgres:

```bash
python3 scripts/sync-telemetry.py --dry-run
```

The sync path stores aggregate usage and performance data only. It does not capture file contents, file paths, or search queries.
24 changes: 24 additions & 0 deletions docs/telemetry/postgres-schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS codedb_events (
id BIGSERIAL PRIMARY KEY,
timestamp_ms BIGINT NOT NULL,
event_type TEXT NOT NULL,
tool TEXT,
latency_ns BIGINT,
error BOOLEAN,
response_bytes INTEGER,
file_count INTEGER,
total_lines BIGINT,
languages TEXT[],
index_size_bytes BIGINT,
startup_time_ms BIGINT,
version TEXT,
platform TEXT,
ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_codedb_events_timestamp_ms
ON codedb_events(timestamp_ms);

CREATE INDEX IF NOT EXISTS idx_codedb_events_tool
ON codedb_events(tool)
WHERE tool IS NOT NULL;
80 changes: 80 additions & 0 deletions scripts/compare-bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Compare codedb benchmark JSON results.")
parser.add_argument("base", help="baseline benchmark JSON")
parser.add_argument("head", help="candidate benchmark JSON")
parser.add_argument("--threshold-pct", type=float, default=10.0, help="maximum allowed latency regression percentage")
parser.add_argument("--markdown-out", help="write markdown report to this path")
return parser.parse_args()


def load_tools(path: str) -> dict[str, dict]:
data = json.loads(Path(path).read_text(encoding="utf-8"))
return {tool["tool"]: tool for tool in data["tools"]}


def pct_change(base_ns: int, head_ns: int) -> float:
if base_ns == 0:
return 0.0
return ((head_ns - base_ns) / base_ns) * 100.0


def render_markdown(rows: list[tuple[str, int, int, float]], threshold_pct: float) -> str:
lines = [
"## Benchmark Regression Report",
"",
f"Threshold: {threshold_pct:.2f}%",
"",
"| Tool | Base (ns) | Head (ns) | Delta | Status |",
"| --- | ---: | ---: | ---: | --- |",
]
for tool, base_ns, head_ns, delta in rows:
status = "FAIL" if delta > threshold_pct else "OK"
lines.append(f"| `{tool}` | {base_ns} | {head_ns} | {delta:+.2f}% | {status} |")
return "\n".join(lines) + "\n"


def main() -> int:
args = parse_args()
base = load_tools(args.base)
head = load_tools(args.head)

missing = sorted(set(base) ^ set(head))
if missing:
print(f"error: tool mismatch: {', '.join(missing)}", file=sys.stderr)
return 1

rows: list[tuple[str, int, int, float]] = []
failures: list[str] = []

for tool in sorted(base):
base_ns = int(base[tool]["avg_latency_ns"])
head_ns = int(head[tool]["avg_latency_ns"])
delta = pct_change(base_ns, head_ns)
rows.append((tool, base_ns, head_ns, delta))
if delta > args.threshold_pct:
failures.append(f"{tool} regressed by {delta:.2f}%")

report = render_markdown(rows, args.threshold_pct)
sys.stdout.write(report)

if args.markdown_out:
Path(args.markdown_out).write_text(report, encoding="utf-8")

if failures:
for failure in failures:
print(failure, file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main())
45 changes: 45 additions & 0 deletions scripts/run-bench-json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import pathlib
import subprocess
import sys


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run `zig build bench -- --json` and persist the JSON payload.")
parser.add_argument("output", help="output JSON file")
return parser.parse_args()


def extract_json(stdout: str, stderr: str) -> str:
text = stdout.strip()
if text.startswith("{") and text.endswith("}"):
return text + "\n"

for stream in (stdout, stderr):
for line in reversed(stream.splitlines()):
line = line.strip()
if line.startswith("{") and line.endswith("}"):
return line + "\n"
raise RuntimeError("benchmark command did not emit JSON")


def main() -> int:
args = parse_args()
proc = subprocess.run(
["zig", "build", "bench", "--", "--json"],
capture_output=True,
text=True,
check=True,
)
if proc.stderr:
sys.stderr.write(proc.stderr)
payload = extract_json(proc.stdout, proc.stderr)
pathlib.Path(args.output).write_text(payload, encoding="utf-8")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading