{"id":51770051,"url":"https://github.com/foundanand/spyglass","last_synced_at":"2026-07-19T23:37:57.868Z","repository":{"id":369346360,"uuid":"1267249985","full_name":"foundanand/spyglass","owner":"foundanand","description":"Air-gap-friendly, self-hosted user analytics, session replay \u0026 bug reporting for small closed-loop apps. Runs fully disconnected — one Go binary, one SQLite file, one npm package, zero outbound calls.","archived":false,"fork":false,"pushed_at":"2026-07-04T19:52:19.000Z","size":722,"stargazers_count":4,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-07-19T23:37:56.502Z","etag":null,"topics":["air-gapped","analytics","bug-reporting","go","observability","offline-first","rrweb","self-hosted","session-replay","sqlite","telemetry"],"latest_commit_sha":null,"homepage":"https://spyglass-analytics.vercel.app/","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"gpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/foundanand.png","metadata":{"files":{"readme":"README.md","changelog":"changelog/001-replay-inspector-and-ui-polish.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-12T11:11:55.000Z","updated_at":"2026-07-17T08:58:53.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/foundanand/spyglass","commit_stats":null,"previous_names":["foundanand/spyglass"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/foundanand/spyglass","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/foundanand%2Fspyglass","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/foundanand%2Fspyglass/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/foundanand%2Fspyglass/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/foundanand%2Fspyglass/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/foundanand","download_url":"https://codeload.github.com/foundanand/spyglass/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/foundanand%2Fspyglass/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35668464,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-19T02:00:06.923Z","response_time":112,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["air-gapped","analytics","bug-reporting","go","observability","offline-first","rrweb","self-hosted","session-replay","sqlite","telemetry"],"created_at":"2026-07-19T23:37:56.986Z","updated_at":"2026-07-19T23:37:57.855Z","avatar_url":"https://github.com/foundanand.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# spyglass\n\nSelf-hosted product analytics, session replay, error tracking, and bug reports\nfor small closed-loop apps. **One Go binary. One SQLite file. One npm package.\nZero external services.**\n\nPostHog, Highlight, and OpenReplay assume billion-event scale — ClickHouse,\nKafka, Kubernetes, gigabytes of RAM. Most internal tools have 20–200 daily users\nand need none of that. spyglass is the telemetry stack for that world: it records\n*every* session continuously, with identified users, so \"what happened when the\nbug occurred\" isn't a capture problem — it's a query over data already on disk.\n\n- **~5KB gzipped SDK.** rrweb loads lazily (~85KB gz), only when replay is on.\n- **~20MB RAM collector, ~21MB Docker image.** Pure-Go SQLite (`modernc.org/sqlite`), no CGo, static binary.\n- **Configure once, never touch again.** One JSON file is the entire ops story.\n- **Air-gap-ready — no phone-home, ever.** Zero outbound calls; runs fully disconnected. GPL-3.0, self-hosted — everything stays on your machine. ([enforced by a test](collector/airgap_test.go).)\n\n---\n\n## Quick start\n\n### 1. Run the collector\n\n```bash\n# Docker — the whole thing, persisted to a volume:\nSPYGLASS_PASS=changeme docker compose up -d\n# → dashboard + collector at http://localhost:7474\n\n# …or build the single binary from source:\nmake build\n./collector/spyglassd --config spyglass.config.json\n```\n\nConfig is one file — copy `spyglass.config.example.json` to\n`spyglass.config.json` and set your app key:\n\n```json\n{\n  \"listen\": \":7474\",\n  \"dataDir\": \"./data\",\n  \"apps\": {\n    \"inventory\": { \"key\": \"sg_live_…\", \"origins\": [\"http://localhost:3000\"] }\n  },\n  \"retention\": { \"replays_days\": 21, \"events_days\": 0 },\n  \"auth\": { \"dashboard_password\": \"env:SPYGLASS_PASS\" }\n}\n```\n\n`replays_days`/`events_days` of `0` means keep forever. The dashboard password is\noptional (empty = open, for local dev); set it and the dashboard plus all query\nendpoints require HTTP Basic auth.\n\nData (SQLite + replay chunks) lives on the `spyglass-data` volume and survives\nrebuilds and restarts. Events are ~200 bytes each; replays dominate storage at\nroughly 0.5–2 MB per user-hour of active use, capped by `replays_days`.\n\n### 2. Add the SDK to your app\n\nThe SDK lives in `sdk/` (`@spyglass/sdk`). It is **not** published to npm —\nbuild it, pack it, and install the tarball (or push it to your own registry):\n\n```bash\n# in this repo\npnpm --filter @spyglass/sdk build\ncd sdk \u0026\u0026 pnpm pack        # → spyglass-sdk-0.0.0.tgz\n\n# in your app\npnpm add file:./vendor/spyglass-sdk-0.0.0.tgz\n```\n\n```ts\nimport { spyglass } from \"@spyglass/sdk\";\n\nspyglass.init({\n  endpoint: \"https://telemetry.internal.acme.dev\",\n  app: \"inventory\",\n  key: \"sg_live_…\",    // app key — must match the collector config\n  user: { id: \"anand\", name: \"Anand\" }, // identified by design\n  replay: true,        // default true — rrweb + console, lazy-loaded\n  network: true,       // default true — method, status, duration, sizes\n  maskInputs: \"password\",\n  reportWidget: true,  // floating bug-report button\n});\n\nspyglass.capture(\"invoice_created\", { amount: 1200 });\nspyglass.report(\"the totals look wrong\");   // programmatic bug report\n```\n\nNext.js app-router pageviews wire up automatically:\n\n```tsx\nimport { SpyglassProvider } from \"@spyglass/sdk/next\";\n\n\u003cSpyglassProvider config={{ endpoint, app: \"inventory\", key, user }}\u003e\n  {children}\n\u003c/SpyglassProvider\u003e\n```\n\nThat's it. Errors, network calls, pageviews, and replay flow in with no further\ncode.\n\n### 3. Integration checklist\n\nFour things that bite real apps — check them before wondering why the dashboard\nis empty:\n\n- **Content-Security-Policy.** The SDK POSTs cross-origin to the collector. If\n  your app ships a CSP, `connect-src 'self'` silently blocks every event and\n  replay chunk — add the collector origin:\n\n  ```\n  connect-src 'self' https://telemetry.internal.acme.dev\n  ```\n\n  Derive it from the same env var the SDK reads so the two can't drift.\n\n- **Identify when your user resolves.** `init()` requires `user.id`, but most\n  apps fetch the session asynchronously. Two patterns that work: defer `init()`\n  until your auth query settles, or init at login with what you have and call\n  `setUser()` when the profile arrives. Mount the provider *inside* your auth\n  gate and you also stop tracking login screens and public pages for free.\n\n- **Make it removable.** Read `endpoint`/`key` from env and skip `init()` when\n  they're unset. Telemetry becomes a config flag, not a code change — and CI /\n  local dev run untracked by default.\n\n- **PII-heavy screens.** Replay records the DOM, so whatever users can see, the\n  replay can show. For apps handling sensitive data start from\n  `maskInputs: \"all\"` and `network: false` (API payloads defeat DOM masking),\n  set a short `replays_days`, and loosen deliberately — not the other way\n  around.\n\n---\n\n## What you get\n\n| Dashboard view | What it shows |\n| --- | --- |\n| **Live feed** | The event stream, filterable by user / type / app. |\n| **Timeline** | Pick a user → sessions → chronological breadcrumbs (pageviews, captures, network, errors). |\n| **Errors** | Every error and bug report, with stack traces; click through to the incident. |\n| **Replay** | Session player with seek, ⏩ skip-idle fast-forward (on by default), event markers on the timeline, and a console pane synced to playback. |\n| **Insights** | DAU, top events, top pages, errors-by-day, and a step funnel. |\n| **Incident** | The killer view: for any error or bug report, the slice `[ts−60s, ts+10s]` from that session — replay auto-cued to the moment, breadcrumb timeline, network waterfall, console, and the stack/comment on top. |\n\nReplays reconstruct the DOM, not pixels. One consequence: if the recorded app\nserves its web fonts without CORS headers, the replay iframe falls back to\nsystem fonts. Cosmetic only — layout and content are exact. Self-host fonts with\n`Access-Control-Allow-Origin` if it bothers you.\n\n---\n\n## API\n\n### Collector endpoints\n\n| Route | Purpose |\n| --- | --- |\n| `POST /v1/events` | Batched JSON events → single-transaction insert (app-key auth). |\n| `POST /v1/replay?session=\u0026seq=` | Gzipped rrweb chunk → disk (app-key auth). |\n| `GET /v1/query/events` | Filtered event stream (`user`, `type`, `app`, `from`, `to`, `limit`). |\n| `GET /v1/query/users` | Active users, last seen, session counts. |\n| `GET /v1/query/sessions` | Session list. |\n| `GET /v1/query/funnel?steps=a,b,c` | Sequential step funnel. |\n| `GET /v1/query/aggregates` | DAU, top events, top pages, errors-by-day. |\n| `GET /v1/sessions/:id/replay` | Chunk manifest + streaming chunk fetch. |\n| `GET /v1/incidents/:event_id` | Incident slice for an error/bug_report. |\n| `GET /` | Embedded dashboard. |\n\nIngest endpoints (`/v1/events`, `/v1/replay`) authenticate with per-app keys.\nEverything else is gated by the dashboard password when one is set.\n\n### SDK surface\n\n```ts\nspyglass.init(config);\nspyglass.capture(name, props?);\nspyglass.setUser({ id, name?, email? });   // late identification\nspyglass.report(comment, extra?);          // programmatic bug report\n```\n\n---\n\n## Privacy defaults\n\n- `maskInputs: \"password\"` minimum, always.\n- Network bodies are opt-in per route prefix (`network: { bodies: [\"/api/\"] }`);\n  `Authorization` / `Cookie` headers are **never** recorded.\n- Replays auto-expire (21 days default); events are tiny and kept by default.\n- No phone-home, no external calls, ever. Everything stays on the operator's box.\n\n---\n\n## Air-gapped / offline deployment\n\nspyglass is built to run inside a disconnected enclave. At runtime the **only**\nnetwork traffic is *browser → collector*, and both live inside your network —\nnothing ever leaves it.\n\n**What's guaranteed (and tested):**\n\n- **The collector makes zero outbound connections.** No update check, no\n  telemetry, no license call, no LLM. Any future opt-in egress must be\n  explicitly marked and reviewed — see the guard below.\n- **The dashboard loads entirely from the binary.** No CDN, no Google Fonts, no\n  remote scripts or stylesheets. A browser with no internet renders it fully.\n- **The SDK only ever talks to your configured `endpoint`** (your collector).\n  rrweb is bundled into your app at build time — it is never fetched from a CDN.\n\nThis is enforced by [`collector/airgap_test.go`](collector/airgap_test.go),\nwhich fails the build (and CI) if an outbound call or external asset slips in.\n\n**Moving it across the boundary:**\n\n- **Collector:** one static, CGo-free binary (`make release` → `darwin/linux ×\n  amd64/arm64`) or the ~21MB Docker image. Copy it in on approved media; there\n  is nothing to install and no runtime dependency to resolve. The database is a\n  single SQLite file, so backup and restore are `cp`.\n- **SDK:** not on npm — `pnpm pack` it to a tarball outside the enclave and\n  vendor it (`pnpm add file:./vendor/spyglass-sdk-*.tgz`), or build your app\n  where the tarball is reachable. Its only runtime dep, rrweb, is bundled.\n- **Upgrades are staggered-safe.** The wire format is versioned (`/v1/`), so the\n  SDK and collector can be updated independently — no lockstep redeploy across\n  the boundary.\n\n**The one thing to watch:** optional features that *would* egress — a Slack\nwebhook on new bug reports, or the auto-summary that POSTs an incident slice to\nan LLM — are **off by default and not yet implemented**. If you enable one, point\nit at an in-enclave endpoint (e.g. a local model), and note that the air-gap\nguard test requires any such call to carry an inline `// airgap:allow \u003creason\u003e`\nmarker so the exception is deliberate and documented, not accidental.\n\nSee also the **Content-Security-Policy** and **web-font** notes in the\n[integration checklist](#3-integration-checklist) — both are the browser side of\nstaying fully self-contained.\n\n---\n\n## Development\n\n```\nspyglass/\n  collector/        Go module: spyglassd (ingest, store, query, embedded dashboard)\n  sdk/              @spyglass/sdk (TypeScript, esbuild)\n  examples/\n    nextjs-demo/    throwaway app that exercises everything\n```\n\n```bash\nmake build      # build dashboard + collector for this host\nmake release    # cross-compile static binaries (darwin/linux × amd64/arm64)\nmake test       # Go + SDK test suites\nmake run        # build, then run against spyglass.config.json\n```\n\nThe dashboard is a Preact SPA embedded into the binary via `go:embed`; there is\nno Node on the server. SQLite runs in WAL mode as a library *inside* the binary —\nthe database is one file, backup is `cp`.\n\n## License\n\nGPL-3.0 — free for commercial use; if you distribute the software, your modifications must be shared. See [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffoundanand%2Fspyglass","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ffoundanand%2Fspyglass","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffoundanand%2Fspyglass/lists"}