telemetry

package
v0.15.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 3, 2026 License: MIT Imports: 14 Imported by: 0

README

shared/telemetry

The CLI side of the anonymous "apps shipped" counter. On a successful nextdeploy ship, RecordShipSuccess sends one signed, anonymous event (opt-out via DO_NOT_TRACK=1, NEXTDEPLOY_TELEMETRY=0, or nextdeploy telemetry off). Events are Ed25519-signed with a key compiled only into official release binaries, so the server can reject forged posts. The rest of this file is the frontend integration guide.


Ship-counter telemetry — frontend integration guide

Audience: the nextdeployfrontend team. This wires the CLI's anonymous "a project shipped" telemetry into a Cloudflare D1 store and surfaces the total on the hero ("🚀 N apps shipped with NextDeploy").

The CLI side is already built (shared/telemetry in the NextDeploy repo). This doc covers the three pieces you own: the ingest endpoint, the D1 store, and the hero read.


1. What the CLI sends

On a successful nextdeploy ship, the CLI POSTs JSON to https://nextdeploy.org/api/telemetry (override: NEXTDEPLOY_TELEMETRY_URL):

{
  "id":      "b3f1...e9",   // random per-install UUID — NOT identifying
  "event":   "ship.success",
  "target":  "cloudflare",  // vps | aws | cloudflare | other
  "version": "v0.12.2",
  "os":      "linux",
  "arch":    "amd64",
  "ts":      1718467200,     // unix seconds
  "nonce":   "9a4c...77"     // random per-event — replay/dedup key
}

Header on signed builds:

X-NextDeploy-Signature: ed25519=<base64 signature>

It carries no project name, domain, repo, paths, env, or secrets — keep it that way; never log the source IP against the payload.

2. Anti-spoofing — this is the important part

The whole point is that a random curl cannot inflate the counter. The CLI signs each event with an Ed25519 private key compiled into official release binaries only. You verify with the matching public key (safe to commit).

Canonical message — sign/verify exactly these 8 fields joined by |, in this order (must byte-match the Go client canonical()):

id|event|target|version|os|arch|ts|nonce

⚠️ Honest threat model: the signing key lives inside a public, downloadable binary, so a determined attacker could extract it via reverse engineering. This is not unbreakable — no open-source self-reporting client can be. It does stop scripted/casual spoofing, and combined with the server checks below (freshness + nonce dedup + rate limit + shape) it makes inflating a vanity counter not worth the effort. If you ever need hard guarantees, the only real path is server-side verification that the deployment exists — which trades away anonymity; treat that as a separate opt-in "verified deploy".

Generate the keypair (one time)
# Private key seed (32 bytes) → base64. Keep secret.
openssl genpkey -algorithm ed25519 -outform DER \
  | tail -c 32 | base64    # → TELEMETRY_SIGNING_KEY

# Public key → base64 (commit this).
# Easiest is a tiny node script (matches what the verifier uses):
node -e '
  const { generateKeyPairSync } = require("crypto");
  const { publicKey, privateKey } = generateKeyPairSync("ed25519");
  const pub = publicKey.export({type:"spki",format:"der"}).subarray(-32);
  const seed = privateKey.export({type:"pkcs8",format:"der"}).subarray(-32);
  console.log("PRIVATE (TELEMETRY_SIGNING_KEY):", seed.toString("base64"));
  console.log("PUBLIC  (commit in frontend):   ", pub.toString("base64"));
'
  • Private → add as repo secret TELEMETRY_SIGNING_KEY in the NextDeploy repo (Settings → Secrets → Actions). The release workflow already passes it to GoReleaser; the next release then ships signed binaries.
  • Public → put in the frontend as TELEMETRY_PUBLIC_KEY (env or constant).

Until the secret exists, release builds are unsigned — the endpoint should reject unsigned events (or bucket them as unverified, uncounted).

3. D1 schema

wrangler.toml:

[[d1_databases]]
binding = "TELEMETRY_DB"
database_name = "nextdeploy-telemetry"
database_id = "<from: wrangler d1 create nextdeploy-telemetry>"

Migration (wrangler d1 execute nextdeploy-telemetry --file ./schema.sql):

CREATE TABLE IF NOT EXISTS ship_events (
  nonce      TEXT PRIMARY KEY,          -- per-event → idempotent dedup
  id         TEXT NOT NULL,             -- install id
  target     TEXT NOT NULL,
  version    TEXT NOT NULL,
  os         TEXT,
  arch       TEXT,
  ts         INTEGER NOT NULL,
  created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ship_events_created ON ship_events(created_at);
-- The hero number is COUNT(*); add a daily rollup later if you want charts.

4. Ingest endpoint — app/api/telemetry/route.ts

Uses @noble/ed25519 (edge-safe; npm i @noble/ed25519). Runs on the Workers runtime.

import { verify } from "@noble/ed25519";

export const runtime = "edge";

const PUBLIC_KEY = process.env.TELEMETRY_PUBLIC_KEY!;          // base64, 32 bytes
const TARGETS = new Set(["vps", "aws", "cloudflare", "other"]);
const MAX_SKEW = 10 * 60;                                      // 10 minutes

const b64 = (s: string) => Uint8Array.from(atob(s), c => c.charCodeAt(0));

export async function POST(req: Request) {
  let ev: any;
  try { ev = await req.json(); } catch { return new Response("bad json", { status: 400 }); }

  // 1. Shape validation — reject anything off-spec.
  if (ev?.event !== "ship.success" || !TARGETS.has(ev?.target)
      || typeof ev?.id !== "string" || typeof ev?.nonce !== "string"
      || typeof ev?.ts !== "number" || typeof ev?.version !== "string") {
    return new Response("bad shape", { status: 400 });
  }

  // 2. Freshness — drop stale/replayed-from-the-future events.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - ev.ts) > MAX_SKEW) return new Response("stale", { status: 400 });

  // 3. Signature — the gate against forged "fake deploy" posts.
  const sig = (req.headers.get("X-NextDeploy-Signature") ?? "").replace(/^ed25519=/, "");
  if (!sig) return new Response("unsigned", { status: 401 });
  const msg = new TextEncoder().encode(
    [ev.id, ev.event, ev.target, ev.version, ev.os, ev.arch, ev.ts, ev.nonce].join("|"),
  );
  const ok = await verify(b64(sig), msg, b64(PUBLIC_KEY)).catch(() => false);
  if (!ok) return new Response("bad signature", { status: 401 });

  // 4. Idempotent insert — nonce PK dedups replays of a valid event.
  const db = (process.env as any).TELEMETRY_DB; // or getRequestContext().env.TELEMETRY_DB
  await db.prepare(
    `INSERT OR IGNORE INTO ship_events (nonce,id,target,version,os,arch,ts,created_at)
     VALUES (?,?,?,?,?,?,?,?)`,
  ).bind(ev.nonce, ev.id, ev.target, ev.version, ev.os ?? "", ev.arch ?? "", ev.ts, now).run();

  return new Response(null, { status: 204 });
}

Add a Cloudflare Rate Limiting rule on /api/telemetry (e.g. 30 req/min/IP) as the volume backstop — signature verification stops forgery, rate limiting stops a valid build being looped.

5. Stats + hero

app/api/stats/route.ts (cache so the hero never hits D1 on every render):

export const runtime = "edge";
export const revalidate = 60;

export async function GET() {
  const db = (process.env as any).TELEMETRY_DB;
  const row = await db.prepare(`SELECT COUNT(*) AS n FROM ship_events`).first<{ n: number }>();
  return Response.json({ shipped: row?.n ?? 0 },
    { headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300" } });
}

Hero (server component — keep D1 server-side):

async function ShipCount() {
  const { shipped } = await fetch("https://nextdeploy.org/api/stats",
    { next: { revalidate: 60 } }).then(r => r.json());
  return <p className="text-sm text-muted-foreground">🚀 {shipped.toLocaleString()} apps shipped with NextDeploy</p>;
}

Optional: an animated count-up on the client, seeded with the server value so it's SSR-safe and degrades without JS.

6. Checklist

  • wrangler d1 create nextdeploy-telemetry + run the migration
  • Generate the Ed25519 keypair (§2)
  • TELEMETRY_SIGNING_KEY (private) → NextDeploy repo Actions secret
  • TELEMETRY_PUBLIC_KEY (public) → frontend env
  • Ship /api/telemetry + /api/stats + bind TELEMETRY_DB
  • Add the CF rate-limit rule on /api/telemetry
  • Drop the count into the hero
  • Cut a NextDeploy release so signed binaries exist, then nextdeploy ship once and confirm a row lands + the hero ticks up

Documentation

Overview

Package telemetry sends a single anonymous "a project shipped" event on a successful deploy. It powers the public "apps shipped" counter on the NextDeploy site.

It is opt-out and deliberately minimal: it never collects identifying information — no project name, domain, repo, file paths, env, or secrets. The entire payload is a random per-install UUID, the deploy target, the nextdeploy version, and OS/arch.

Disable it with any of:

  • DO_NOT_TRACK=1 (the cross-tool standard)
  • NEXTDEPLOY_TELEMETRY=0
  • nextdeploy telemetry off (writes a local marker)

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Disable

func Disable() error

Disable writes the local opt-out marker.

func Enable

func Enable() error

Enable removes the local opt-out marker (no-op if absent).

func Enabled

func Enabled() bool

Enabled reports whether telemetry should run. Opt-out model: on unless the user turned it off via env or the local marker.

func RecordShipSuccess

func RecordShipSuccess(target, version string)

RecordShipSuccess reports one successful deploy. It resolves opt-out, prints the one-time disclosure, and sends the event synchronously with a short timeout. Every failure is swallowed: telemetry must never affect a deploy.

func StatusLine

func StatusLine() string

StatusLine returns a human-readable on/off summary for `telemetry status`.

Types

type Event

type Event struct {
	ID      string `json:"id"`      // random per-install UUID — not identifying
	Event   string `json:"event"`   // e.g. "ship.success"
	Target  string `json:"target"`  // vps | aws | cloudflare | other
	Version string `json:"version"` // nextdeploy version
	OS      string `json:"os"`      // runtime.GOOS
	Arch    string `json:"arch"`    // runtime.GOARCH
	TS      int64  `json:"ts"`      // unix seconds
	Nonce   string `json:"nonce"`   // random per-event — replay protection / dedup
}

Event is the entire telemetry payload. Anonymous by construction.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL