mirror of
https://github.com/lukaszraczylo/oss-telemetry.git
synced 2026-10-12 00:45:30 +00:00
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
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
.docs
|
||||||
@@ -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
|
||||||
@@ -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 | `<UPPER_PROJECT>_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.
|
||||||
+231
@@ -0,0 +1,231 @@
|
|||||||
|
// Package telemetry sends anonymous usage pings for open-source Go projects.
|
||||||
|
//
|
||||||
|
// Wire format (POST application/json):
|
||||||
|
//
|
||||||
|
// {"project":"<name>","version":"<ver>","ts":<unix-seconds>}
|
||||||
|
//
|
||||||
|
// 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 <UPPER_PROJECT>_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 "<UPPER_PROJECT>_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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user