commit e02d51419c5228b83c3dab3f127af046b7acaa97 Author: Lukasz Raczylo Date: Thu May 21 01:58:11 2026 +0100 feat: initial fire-and-forget telemetry client Anonymous OSS usage telemetry library with zero third-party deps. - Send(project, version): fire-and-forget POST to oss.raczylo.com/v1/ping - Disable() and Wait(timeout) helpers - Disable via DO_NOT_TRACK, OSS_TELEMETRY_DISABLED, or per-project env - Failproof: never blocks, never panics, never returns errors - Hand-written JSON (no encoding/json) and byte-loop validators (no regexp) - golangci-lint v2 clean, all tests pass with -race diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d18a6af --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.docs diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..1c8ff72 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,36 @@ +version: "2" + +run: + timeout: 2m + +linters: + default: none + enable: + - bodyclose + - errcheck + - errorlint + - gocritic + - gocyclo + - govet + - ineffassign + - misspell + - prealloc + - revive + - staticcheck + - unconvert + - unused + settings: + gocyclo: + min-complexity: 12 + revive: + rules: + - name: var-naming + - name: indent-error-flow + - name: superfluous-else + - name: unused-parameter + - name: redefines-builtin-id + +formatters: + enable: + - gofmt + - goimports diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce7a101 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# oss-telemetry + +A tiny Go client that fires one anonymous "this binary started" ping at a +central ingest endpoint. Designed to be embedded in your own open-source +projects so you can see approximate adoption and version spread without +collecting anything that could identify a user. + +This is the **client library only**. The ingest endpoint, server-side +deduplication, rate limiting, and metrics are out of scope here. + +## What it sends + +A single HTTP `POST` per call to `Send`: + +```json +{ + "project": "my-tool", + "version": "1.2.3", + "ts": 1747782200 +} +``` + +No identifiers, no IP, no machine info, no user data. The server dedupes +incoming requests; the client just fires and forgets. + +## Failproof by design + +- Never blocks the caller — work runs in a goroutine. +- Never panics — the goroutine recovers internally. +- Never returns errors — bad input and network failures are silently dropped. +- Never retries, never persists state, never reads back. +- 2-second hard timeout on every request. +- Zero third-party dependencies (Go stdlib only). + +The endpoint is hardcoded and not overridable from consuming code, by design. + +## Install + +```bash +go get github.com/lukaszraczylo/oss-telemetry +``` + +Requires Go 1.22+. + +## Usage + +```go +package main + +import ( + "time" + + telemetry "github.com/lukaszraczylo/oss-telemetry" +) + +const version = "1.2.3" + +func main() { + telemetry.Send("my-tool", version) + + // ... your program runs ... + + // Only needed for short-lived CLIs that may exit before the goroutine + // finishes its POST. Long-running services do not need this. + telemetry.Wait(2 * time.Second) +} +``` + +Call `Send` once at boot. Calling it more often just sends more pings; the +server deduplicates. + +## How users disable telemetry + +Any one of these turns it off: + +| Mechanism | How | +| ---------------------------------------- | ---------------------------------------------------------------- | +| Universal opt-out | `DO_NOT_TRACK=1` | +| Library-wide opt-out | `OSS_TELEMETRY_DISABLED=1` | +| Project-specific opt-out | `_DISABLE_TELEMETRY=1` | +| Programmatic (e.g. behind a `--no-telemetry` flag) | `telemetry.Disable()` before the first `Send` | + +Project-specific env var derivation: uppercase the project name and replace +`-` with `_`. For `my-tool` the variable is `MY_TOOL_DISABLE_TELEMETRY`. + +Truthy values: `1`, `true`, `yes`, `on` (case-insensitive). Anything else is +treated as "not set". + +## Validation rules (silently dropped if violated) + +- `project`: matches `^[a-z0-9-]+$`, length 1–64. +- `version`: matches `^[A-Za-z0-9.+_-]+$`, length 1–32. + +Bad input is a no-op — the library never logs, never errors, never crashes. + +## API + +```go +// Fire a single ping in the background. Returns immediately. +func Send(project, version string) + +// Suppress all subsequent Send calls in this process. Idempotent. +func Disable() + +// Block until in-flight pings complete or timeout elapses, whichever first. +// Useful for short-lived CLI processes. +func Wait(timeout time.Duration) +``` + +## Testing + +```bash +go test -race ./... +``` + +## License + +Pick one before publishing. None bundled. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5b0ec56 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/lukaszraczylo/oss-telemetry + +go 1.22 diff --git a/telemetry.go b/telemetry.go new file mode 100644 index 0000000..ce4e487 --- /dev/null +++ b/telemetry.go @@ -0,0 +1,231 @@ +// Package telemetry sends anonymous usage pings for open-source Go projects. +// +// Wire format (POST application/json): +// +// {"project":"","version":"","ts":} +// +// Design contract (failproof): +// - never blocks the caller (work happens in a goroutine) +// - never panics (background goroutine recovers internally) +// - never returns errors (silently no-ops on bad input or network failure) +// - never retries, never deduplicates, never persists state — the client +// fires a single ping and forgets; the server is responsible for +// deduplication, abuse protection, and aggregation +// +// Typical usage at program startup: +// +// telemetry.Send("my-tool", "1.2.3") +// +// For short-lived CLI processes that may exit before the goroutine finishes: +// +// telemetry.Send("my-tool", "1.2.3") +// defer telemetry.Wait(2 * time.Second) +// +// Disablement (any one of these suppresses pings): +// - environment variable DO_NOT_TRACK=1 +// - environment variable OSS_TELEMETRY_DISABLED=1 +// - environment variable _DISABLE_TELEMETRY=1 +// (project name uppercased, dashes replaced with underscores) +// - calling telemetry.Disable() at runtime +package telemetry + +import ( + "bytes" + "context" + "net/http" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + defaultEndpoint = "https://oss.raczylo.com/v1/ping" + httpTimeout = 2 * time.Second + maxProjectLen = 64 + maxVersionLen = 32 +) + +// endpoint holds the ingest URL. Production code never mutates it; it is +// atomic only so the package's own test suite can safely retarget it at +// httptest servers while goroutines started by Send are still in flight. +var endpoint atomic.Pointer[string] + +var ( + disabled atomic.Bool + inflight sync.WaitGroup + + client = &http.Client{Timeout: httpTimeout} +) + +func init() { + s := defaultEndpoint + endpoint.Store(&s) +} + +// Send fires a single anonymous telemetry ping in the background and returns +// immediately. It never blocks, never panics, and never reports errors. +// Invalid inputs, disabled state, and network failures are silently dropped. +// +// Call once at program startup. Calling repeatedly will send repeated pings; +// the server is responsible for deduplication. +func Send(project, version string) { + if disabled.Load() { + return + } + if isDisabledByEnv(project) { + return + } + if !validProject(project) || !validVersion(version) { + return + } + + inflight.Add(1) + go func() { + defer inflight.Done() + defer func() { _ = recover() }() + dispatch(project, version) + }() +} + +// Disable suppresses all subsequent Send calls in this process. +// Idempotent and safe to call from any goroutine. +func Disable() { + disabled.Store(true) +} + +// Wait blocks until all in-flight pings have completed, or until timeout +// elapses — whichever comes first. Useful for short-lived CLI processes +// that may otherwise exit before the background goroutine finishes its POST. +// +// A non-positive timeout returns immediately. +func Wait(timeout time.Duration) { + if timeout <= 0 { + return + } + done := make(chan struct{}) + go func() { + inflight.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(timeout): + } +} + +func dispatch(project, version string) { + body := buildPayload(project, version, time.Now().Unix()) + + ctx, cancel := context.WithTimeout(context.Background(), httpTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, *endpoint.Load(), bytes.NewReader(body)) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return + } + _ = resp.Body.Close() +} + +// buildPayload writes the JSON body without encoding/json. The validators +// restrict project and version to characters that never require JSON +// escaping, so direct concatenation is safe. +func buildPayload(project, version string, ts int64) []byte { + // Wrapper text plus 20 chars for a signed int64. + const overhead = len(`{"project":"","version":"","ts":}`) + 20 + buf := make([]byte, 0, len(project)+len(version)+overhead) + buf = append(buf, `{"project":"`...) + buf = append(buf, project...) + buf = append(buf, `","version":"`...) + buf = append(buf, version...) + buf = append(buf, `","ts":`...) + buf = strconv.AppendInt(buf, ts, 10) + buf = append(buf, '}') + return buf +} + +func validProject(p string) bool { + n := len(p) + if n == 0 || n > maxProjectLen { + return false + } + for i := range n { + c := p[i] + switch { + case c >= 'a' && c <= 'z', + c >= '0' && c <= '9', + c == '-': + default: + return false + } + } + return true +} + +func validVersion(v string) bool { + n := len(v) + if n == 0 || n > maxVersionLen { + return false + } + for i := range n { + c := v[i] + switch { + case c >= 'A' && c <= 'Z', + c >= 'a' && c <= 'z', + c >= '0' && c <= '9', + c == '.', c == '+', c == '_', c == '-': + default: + return false + } + } + return true +} + +func isDisabledByEnv(project string) bool { + if truthy(os.Getenv("DO_NOT_TRACK")) { + return true + } + if truthy(os.Getenv("OSS_TELEMETRY_DISABLED")) { + return true + } + if project == "" { + return false + } + key := projectEnvKey(project) + return truthy(os.Getenv(key)) +} + +// projectEnvKey returns "_DISABLE_TELEMETRY" using a single +// allocation rather than chained strings.ToUpper(strings.ReplaceAll(...)). +func projectEnvKey(project string) string { + const suffix = "_DISABLE_TELEMETRY" + buf := make([]byte, 0, len(project)+len(suffix)) + for i := range len(project) { + c := project[i] + switch { + case c == '-': + c = '_' + case c >= 'a' && c <= 'z': + c -= 'a' - 'A' + } + buf = append(buf, c) + } + buf = append(buf, suffix...) + return string(buf) +} + +func truthy(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "1", "true", "yes", "on": + return true + } + return false +} diff --git a/telemetry_test.go b/telemetry_test.go new file mode 100644 index 0000000..49ea9d6 --- /dev/null +++ b/telemetry_test.go @@ -0,0 +1,406 @@ +package telemetry + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// pingPayload mirrors the wire format produced by buildPayload, for use in +// test-side unmarshalling. Production code constructs JSON manually to avoid +// an encoding/json dependency. +type pingPayload struct { + Project string `json:"project"` + Version string `json:"version"` + Ts int64 `json:"ts"` +} + +// resetState restores package-level mutable state so tests do not contaminate +// each other. Tests share `disabled`, `endpoint`, and `inflight`. +func resetState(t *testing.T) { + t.Helper() + disabled.Store(false) + t.Cleanup(func() { + disabled.Store(false) + }) +} + +// setEndpoint atomically retargets the ingest URL for the duration of a test +// and restores it on cleanup. Safe even if Send goroutines outlive the test. +func setEndpoint(t *testing.T, url string) { + t.Helper() + prev := *endpoint.Load() + s := url + endpoint.Store(&s) + t.Cleanup(func() { + p := prev + endpoint.Store(&p) + }) +} + +// captureServer spins up a test server that records every received body. +// It restores the package endpoint when the test finishes. +type captureServer struct { + srv *httptest.Server + mu sync.Mutex + requests []pingPayload + hits int32 +} + +func newCaptureServer(t *testing.T, status int) *captureServer { + t.Helper() + c := &captureServer{} + c.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&c.hits, 1) + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + var p pingPayload + _ = json.Unmarshal(body, &p) + c.mu.Lock() + c.requests = append(c.requests, p) + c.mu.Unlock() + w.WriteHeader(status) + })) + + setEndpoint(t, c.srv.URL) + t.Cleanup(c.srv.Close) + return c +} + +func (c *captureServer) Hits() int { + return int(atomic.LoadInt32(&c.hits)) +} + +func (c *captureServer) Last() (pingPayload, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.requests) == 0 { + return pingPayload{}, false + } + return c.requests[len(c.requests)-1], true +} + +func TestSend_ValidPayloadReachesServer(t *testing.T) { + resetState(t) + srv := newCaptureServer(t, http.StatusNoContent) + + Send("my-tool", "1.2.3") + Wait(2 * time.Second) + + if srv.Hits() != 1 { + t.Fatalf("expected exactly 1 hit, got %d", srv.Hits()) + } + got, ok := srv.Last() + if !ok { + t.Fatal("no recorded request") + } + if got.Project != "my-tool" || got.Version != "1.2.3" { + t.Fatalf("unexpected payload: %+v", got) + } + if got.Ts <= 0 { + t.Fatalf("expected positive ts, got %d", got.Ts) + } + now := time.Now().Unix() + if got.Ts > now+5 || got.Ts < now-300 { + t.Fatalf("ts %d outside acceptable window around %d", got.Ts, now) + } +} + +func TestSend_InvalidProjectIsDropped(t *testing.T) { + resetState(t) + srv := newCaptureServer(t, http.StatusNoContent) + + bad := []string{ + "", + "UPPER", + "with space", + "under_score", + "slash/forbidden", + strings.Repeat("a", 65), + } + for _, p := range bad { + Send(p, "1.0.0") + } + Wait(500 * time.Millisecond) + + if srv.Hits() != 0 { + t.Fatalf("expected 0 hits for invalid projects, got %d", srv.Hits()) + } +} + +func TestSend_InvalidVersionIsDropped(t *testing.T) { + resetState(t) + srv := newCaptureServer(t, http.StatusNoContent) + + bad := []string{ + "", + "has space", + "semi;colon", + strings.Repeat("1", 33), + } + for _, v := range bad { + Send("good-name", v) + } + Wait(500 * time.Millisecond) + + if srv.Hits() != 0 { + t.Fatalf("expected 0 hits for invalid versions, got %d", srv.Hits()) + } +} + +func TestSend_AcceptsCommonVersionShapes(t *testing.T) { + resetState(t) + srv := newCaptureServer(t, http.StatusNoContent) + + good := []string{"1.2.3", "1.4.0-beta1", "2.0", "v1.0.0", "1.0.0+meta"} + for _, v := range good { + Send("tool", v) + } + Wait(2 * time.Second) + + if srv.Hits() != len(good) { + t.Fatalf("expected %d hits, got %d", len(good), srv.Hits()) + } +} + +func TestDisable_StopsSubsequentSends(t *testing.T) { + resetState(t) + srv := newCaptureServer(t, http.StatusNoContent) + + Disable() + Send("tool", "1.0.0") + Wait(500 * time.Millisecond) + + if srv.Hits() != 0 { + t.Fatalf("expected 0 hits after Disable(), got %d", srv.Hits()) + } +} + +func TestEnv_DO_NOT_TRACK_DisablesSends(t *testing.T) { + resetState(t) + srv := newCaptureServer(t, http.StatusNoContent) + + t.Setenv("DO_NOT_TRACK", "1") + Send("tool", "1.0.0") + Wait(500 * time.Millisecond) + + if srv.Hits() != 0 { + t.Fatalf("expected 0 hits with DO_NOT_TRACK=1, got %d", srv.Hits()) + } +} + +func TestEnv_OSS_TELEMETRY_DISABLED_DisablesSends(t *testing.T) { + resetState(t) + srv := newCaptureServer(t, http.StatusNoContent) + + t.Setenv("OSS_TELEMETRY_DISABLED", "true") + Send("tool", "1.0.0") + Wait(500 * time.Millisecond) + + if srv.Hits() != 0 { + t.Fatalf("expected 0 hits with OSS_TELEMETRY_DISABLED=true, got %d", srv.Hits()) + } +} + +func TestEnv_ProjectSpecific_DisablesSends(t *testing.T) { + resetState(t) + srv := newCaptureServer(t, http.StatusNoContent) + + t.Setenv("MY_TOOL_DISABLE_TELEMETRY", "yes") + Send("my-tool", "1.0.0") + Send("other-tool", "1.0.0") + Wait(2 * time.Second) + + if srv.Hits() != 1 { + t.Fatalf("expected 1 hit (other-tool only), got %d", srv.Hits()) + } + got, _ := srv.Last() + if got.Project != "other-tool" { + t.Fatalf("expected other-tool to be sent, got %q", got.Project) + } +} + +func TestEnv_FalsyValuesDoNotDisable(t *testing.T) { + resetState(t) + srv := newCaptureServer(t, http.StatusNoContent) + + t.Setenv("DO_NOT_TRACK", "0") + t.Setenv("OSS_TELEMETRY_DISABLED", "false") + Send("tool", "1.0.0") + Wait(2 * time.Second) + + if srv.Hits() != 1 { + t.Fatalf("expected 1 hit with falsy env, got %d", srv.Hits()) + } +} + +func TestSend_DoesNotBlock(t *testing.T) { + resetState(t) + // Server that never responds — verifies caller is not blocked on slow networks. + hung := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + time.Sleep(5 * time.Second) + })) + setEndpoint(t, hung.URL) + t.Cleanup(hung.Close) + + start := time.Now() + Send("tool", "1.0.0") + elapsed := time.Since(start) + + if elapsed > 50*time.Millisecond { + t.Fatalf("Send blocked for %v, expected near-instant return", elapsed) + } +} + +func TestSend_SurvivesServerError(t *testing.T) { + resetState(t) + srv := newCaptureServer(t, http.StatusInternalServerError) + + // Must not panic, must complete cleanly even though server returns 500. + Send("tool", "1.0.0") + Wait(2 * time.Second) + + if srv.Hits() != 1 { + t.Fatalf("expected the request to still reach server (got %d)", srv.Hits()) + } +} + +func TestSend_SurvivesUnreachableEndpoint(t *testing.T) { + resetState(t) + // Reserved TEST-NET-1 address per RFC 5737 — guaranteed unroutable. + setEndpoint(t, "http://192.0.2.1:1/v1/ping") + + // Should not panic and should complete within the timeout budget. + Send("tool", "1.0.0") + Wait(3 * time.Second) +} + +func TestWait_ReturnsImmediatelyWhenIdle(t *testing.T) { + resetState(t) + start := time.Now() + Wait(5 * time.Second) + if elapsed := time.Since(start); elapsed > 50*time.Millisecond { + t.Fatalf("Wait blocked %v while idle", elapsed) + } +} + +func TestWait_NonPositiveTimeoutReturnsImmediately(t *testing.T) { + resetState(t) + start := time.Now() + Wait(0) + Wait(-1 * time.Second) + if elapsed := time.Since(start); elapsed > 10*time.Millisecond { + t.Fatalf("Wait with non-positive timeout blocked %v", elapsed) + } +} + +func TestValidationHelpers(t *testing.T) { + resetState(t) + + cases := []struct { + project string + want bool + }{ + {"a", true}, + {"my-tool-7", true}, + {"123", true}, + {"", false}, + {"Foo", false}, + {"under_score", false}, + {strings.Repeat("a", 64), true}, + {strings.Repeat("a", 65), false}, + } + for _, c := range cases { + if got := validProject(c.project); got != c.want { + t.Errorf("validProject(%q) = %v, want %v", c.project, got, c.want) + } + } + + vcases := []struct { + version string + want bool + }{ + {"1.2.3", true}, + {"1.4.0-beta1", true}, + {"v1.0.0", true}, + {"1.0.0+meta", true}, + {"", false}, + {"has space", false}, + {strings.Repeat("1", 32), true}, + {strings.Repeat("1", 33), false}, + } + for _, c := range vcases { + if got := validVersion(c.version); got != c.want { + t.Errorf("validVersion(%q) = %v, want %v", c.version, got, c.want) + } + } +} + +func TestBuildPayload(t *testing.T) { + resetState(t) + + body := buildPayload("my-tool", "1.2.3", 1747782200) + want := `{"project":"my-tool","version":"1.2.3","ts":1747782200}` + if string(body) != want { + t.Fatalf("buildPayload mismatch:\n got %q\nwant %q", string(body), want) + } + + var p pingPayload + if err := json.Unmarshal(body, &p); err != nil { + t.Fatalf("buildPayload produced invalid JSON: %v", err) + } + if p.Project != "my-tool" || p.Version != "1.2.3" || p.Ts != 1747782200 { + t.Fatalf("round-trip mismatch: %+v", p) + } +} + +func TestProjectEnvKey(t *testing.T) { + resetState(t) + + cases := map[string]string{ + "my-tool": "MY_TOOL_DISABLE_TELEMETRY", + "abc": "ABC_DISABLE_TELEMETRY", + "a-b-c-d": "A_B_C_D_DISABLE_TELEMETRY", + "123-tool": "123_TOOL_DISABLE_TELEMETRY", + } + for in, want := range cases { + if got := projectEnvKey(in); got != want { + t.Errorf("projectEnvKey(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSend_DoesNotAllocateWhenDisabled(t *testing.T) { + resetState(t) + Disable() + + allocs := testing.AllocsPerRun(1000, func() { + Send("tool", "1.0.0") + }) + if allocs != 0 { + t.Fatalf("Send while disabled should be zero-alloc, got %.1f allocs/op", allocs) + } +} + +func TestTruthy(t *testing.T) { + resetState(t) + + truthyCases := []string{"1", "true", "TRUE", "Yes", "on", " 1 "} + for _, s := range truthyCases { + if !truthy(s) { + t.Errorf("truthy(%q) = false, want true", s) + } + } + falsyCases := []string{"", "0", "false", "no", "off", "anything else"} + for _, s := range falsyCases { + if truthy(s) { + t.Errorf("truthy(%q) = true, want false", s) + } + } +}