mirror of
https://github.com/lukaszraczylo/oss-telemetry.git
synced 2026-10-14 00:57:57 +00:00
feat: SendForModule + tighten validVersion to match receiver
Add SendForModule/ResolveModuleVersion helpers so consumers no longer need to hand-maintain a bumped version constant; the library resolves the running module version from runtime/debug.BuildInfo (Deps[] for library consumers, Main for go-install'ed binaries) and falls back to the caller-supplied string otherwise. Leading "v" is stripped to match the canonical wire form the receiver stores. Tighten validVersion: previously any [A-Za-z0-9._+-] passed client-side but the receiver rejects anything that does not start with a digit, so "dev"/"next"/"latest" pings were silently 400'd at the edge with no client signal. Now validVersion enforces the same SemVer-ish shape as the receiver (with optional leading v stripped), so junk versions drop at the source without a wasted HTTP roundtrip. normalizeVersion strips the leading v on the wire so callers may pass either form. New tests cover the resolver fallback path, leading-v normalization, and rejection of non-semver junk. Existing test contract (Send accepts "v1.0.0", "1.0.0+meta", etc.) is preserved.
This commit is contained in:
+132
-10
@@ -34,6 +34,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime/debug"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -69,6 +70,11 @@ func init() {
|
|||||||
// immediately. It never blocks, never panics, and never reports errors.
|
// immediately. It never blocks, never panics, and never reports errors.
|
||||||
// Invalid inputs, disabled state, and network failures are silently dropped.
|
// Invalid inputs, disabled state, and network failures are silently dropped.
|
||||||
//
|
//
|
||||||
|
// Version strings are validated against a SemVer-ish shape that mirrors the
|
||||||
|
// receiver. An optional leading "v" or "V" is accepted and stripped before
|
||||||
|
// transmission so that callers can pass either "v1.2.3" or "1.2.3"; the
|
||||||
|
// wire form is always the unprefixed canonical version.
|
||||||
|
//
|
||||||
// Call once at program startup. Calling repeatedly will send repeated pings;
|
// Call once at program startup. Calling repeatedly will send repeated pings;
|
||||||
// the server is responsible for deduplication.
|
// the server is responsible for deduplication.
|
||||||
func Send(project, version string) {
|
func Send(project, version string) {
|
||||||
@@ -81,15 +87,60 @@ func Send(project, version string) {
|
|||||||
if !validProject(project) || !validVersion(version) {
|
if !validProject(project) || !validVersion(version) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
canonical := normalizeVersion(version)
|
||||||
|
|
||||||
inflight.Add(1)
|
inflight.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer inflight.Done()
|
defer inflight.Done()
|
||||||
defer func() { _ = recover() }()
|
defer func() { _ = recover() }()
|
||||||
dispatch(project, version)
|
dispatch(project, canonical)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendForModule is the recommended call form for Go libraries: it resolves
|
||||||
|
// the version automatically from Go's build info for the given module path
|
||||||
|
// so consumers do not need to maintain a hand-bumped version constant in
|
||||||
|
// source. Behaviour and contract are otherwise identical to [Send].
|
||||||
|
//
|
||||||
|
// Resolution order:
|
||||||
|
//
|
||||||
|
// 1. debug.ReadBuildInfo Deps entry for modulePath (authoritative when the
|
||||||
|
// library is consumed via go.mod);
|
||||||
|
// 2. debug.ReadBuildInfo Main when the library is itself the main module
|
||||||
|
// (e.g. running its own tests or examples);
|
||||||
|
// 3. fallback parameter, used only when build info is unavailable or
|
||||||
|
// unhelpful (replace directives, detached `go run`, ldflag override).
|
||||||
|
//
|
||||||
|
// Any leading "v" reported by build info is stripped to match the canonical
|
||||||
|
// wire form. Empty / "(devel)" build versions are skipped in favour of the
|
||||||
|
// next resolution source. Typical usage:
|
||||||
|
//
|
||||||
|
// telemetry.SendForModule("my-tool", "github.com/me/my-tool", "0.0.0-dev")
|
||||||
|
func SendForModule(project, modulePath, fallback string) {
|
||||||
|
Send(project, ResolveModuleVersion(modulePath, fallback))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveModuleVersion implements the version resolution used by
|
||||||
|
// SendForModule. Exposed for callers that need to format the resolved
|
||||||
|
// version (e.g. logging) without firing a ping.
|
||||||
|
func ResolveModuleVersion(modulePath, fallback string) string {
|
||||||
|
if info, ok := debug.ReadBuildInfo(); ok {
|
||||||
|
for _, d := range info.Deps {
|
||||||
|
if d != nil && d.Path == modulePath && isUsableBuildVersion(d.Version) {
|
||||||
|
return strings.TrimPrefix(d.Version, "v")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if info.Main.Path == modulePath && isUsableBuildVersion(info.Main.Version) {
|
||||||
|
return strings.TrimPrefix(info.Main.Version, "v")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUsableBuildVersion(v string) bool {
|
||||||
|
return v != "" && v != "(devel)"
|
||||||
|
}
|
||||||
|
|
||||||
// Disable suppresses all subsequent Send calls in this process.
|
// Disable suppresses all subsequent Send calls in this process.
|
||||||
// Idempotent and safe to call from any goroutine.
|
// Idempotent and safe to call from any goroutine.
|
||||||
func Disable() {
|
func Disable() {
|
||||||
@@ -170,23 +221,94 @@ func validProject(p string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validVersion accepts SemVer-ish version strings with an optional leading
|
||||||
|
// "v"/"V" prefix. Acceptable shape (after stripping the leading v):
|
||||||
|
//
|
||||||
|
// MAJOR[.MINOR[.PATCH]] ("-"prerelease)? ("+"build)?
|
||||||
|
//
|
||||||
|
// where MAJOR/MINOR/PATCH are ASCII digit sequences and the prerelease/build
|
||||||
|
// payloads are non-empty runs of [0-9A-Za-z.-]. This intentionally mirrors
|
||||||
|
// the receiver's version regex so junk like "dev" or "git-2026-05-22" never
|
||||||
|
// leaves the client (where it would only be rejected with HTTP 400 anyway).
|
||||||
func validVersion(v string) bool {
|
func validVersion(v string) bool {
|
||||||
n := len(v)
|
n := len(v)
|
||||||
if n == 0 || n > maxVersionLen {
|
if n == 0 || n > maxVersionLen {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for i := range n {
|
if v[0] == 'v' || v[0] == 'V' {
|
||||||
c := v[i]
|
v = v[1:]
|
||||||
switch {
|
}
|
||||||
case c >= 'A' && c <= 'Z',
|
if len(v) == 0 {
|
||||||
c >= 'a' && c <= 'z',
|
return false
|
||||||
c >= '0' && c <= '9',
|
}
|
||||||
c == '.', c == '+', c == '_', c == '-':
|
return checkSemverShape(v)
|
||||||
default:
|
}
|
||||||
|
|
||||||
|
// normalizeVersion strips an optional leading "v"/"V" so the on-the-wire
|
||||||
|
// version matches the form stored server-side by the version refresher cron
|
||||||
|
// (which also strips the leading v from release tags). Callers may pass
|
||||||
|
// either "v1.2.3" or "1.2.3" — only the unprefixed form is transmitted.
|
||||||
|
func normalizeVersion(v string) string {
|
||||||
|
if len(v) > 0 && (v[0] == 'v' || v[0] == 'V') {
|
||||||
|
return v[1:]
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkSemverShape(s string) bool {
|
||||||
|
i := 0
|
||||||
|
if !readDigitRun(s, &i) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for groups := 0; groups < 2 && i < len(s) && s[i] == '.'; groups++ {
|
||||||
|
i++
|
||||||
|
if !readDigitRun(s, &i) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
if i < len(s) && s[i] == '-' {
|
||||||
|
i++
|
||||||
|
if !readIdentRun(s, &i, '+') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i < len(s) && s[i] == '+' {
|
||||||
|
i++
|
||||||
|
if !readIdentRun(s, &i, 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return i == len(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDigitRun(s string, i *int) bool {
|
||||||
|
start := *i
|
||||||
|
for *i < len(s) && s[*i] >= '0' && s[*i] <= '9' {
|
||||||
|
*i++
|
||||||
|
}
|
||||||
|
return *i > start
|
||||||
|
}
|
||||||
|
|
||||||
|
// readIdentRun consumes [0-9A-Za-z.-] until end-of-string or until `stop`
|
||||||
|
// is hit (stop=0 disables the early-stop check). Returns false if no
|
||||||
|
// characters were consumed (i.e. empty payload).
|
||||||
|
func readIdentRun(s string, i *int, stop byte) bool {
|
||||||
|
start := *i
|
||||||
|
for *i < len(s) {
|
||||||
|
c := s[*i]
|
||||||
|
if stop != 0 && c == stop {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
valid := (c >= '0' && c <= '9') ||
|
||||||
|
(c >= 'A' && c <= 'Z') ||
|
||||||
|
(c >= 'a' && c <= 'z') ||
|
||||||
|
c == '.' || c == '-'
|
||||||
|
if !valid {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
*i++
|
||||||
|
}
|
||||||
|
return *i > start
|
||||||
}
|
}
|
||||||
|
|
||||||
func isDisabledByEnv(project string) bool {
|
func isDisabledByEnv(project string) bool {
|
||||||
|
|||||||
@@ -169,6 +169,98 @@ func TestSend_AcceptsCommonVersionShapes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSend_RejectsNonSemverJunk(t *testing.T) {
|
||||||
|
resetState(t)
|
||||||
|
srv := newCaptureServer(t, http.StatusNoContent)
|
||||||
|
|
||||||
|
// These match the old permissive regex but the receiver always
|
||||||
|
// rejected them with HTTP 400. The library now drops them client-side
|
||||||
|
// so callers like GMP's unset `appVersion = "dev"` produce zero traffic
|
||||||
|
// rather than a stream of HTTP 400s.
|
||||||
|
junk := []string{"dev", "next", "latest", "snapshot", "git-2026-05-22", "alpha", "abc.def"}
|
||||||
|
for _, v := range junk {
|
||||||
|
Send("tool", v)
|
||||||
|
}
|
||||||
|
Wait(500 * time.Millisecond)
|
||||||
|
|
||||||
|
if srv.Hits() != 0 {
|
||||||
|
t.Fatalf("expected 0 hits for non-semver junk, got %d", srv.Hits())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_NormalizesLeadingV(t *testing.T) {
|
||||||
|
resetState(t)
|
||||||
|
srv := newCaptureServer(t, http.StatusNoContent)
|
||||||
|
|
||||||
|
Send("tool", "v1.2.3")
|
||||||
|
Send("tool", "V9.0.0")
|
||||||
|
Wait(2 * time.Second)
|
||||||
|
|
||||||
|
if srv.Hits() != 2 {
|
||||||
|
t.Fatalf("expected 2 hits, got %d", srv.Hits())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent goroutines may arrive in any order, so compare the SET.
|
||||||
|
srv.mu.Lock()
|
||||||
|
defer srv.mu.Unlock()
|
||||||
|
got := make(map[string]struct{}, len(srv.requests))
|
||||||
|
for _, req := range srv.requests {
|
||||||
|
if strings.HasPrefix(req.Version, "v") || strings.HasPrefix(req.Version, "V") {
|
||||||
|
t.Fatalf("wire version still has leading v: %q", req.Version)
|
||||||
|
}
|
||||||
|
got[req.Version] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, want := range []string{"1.2.3", "9.0.0"} {
|
||||||
|
if _, ok := got[want]; !ok {
|
||||||
|
t.Fatalf("missing normalized %q in wire versions %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveModuleVersion_FallbackWhenUnknownModule(t *testing.T) {
|
||||||
|
// "nonexistent.example/none" will never appear in debug.BuildInfo of the
|
||||||
|
// test binary, so resolution must fall through to the fallback parameter.
|
||||||
|
got := ResolveModuleVersion("nonexistent.example/none", "0.0.0-fallback")
|
||||||
|
if got != "0.0.0-fallback" {
|
||||||
|
t.Fatalf("expected fallback, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveModuleVersion_StripsLeadingVFromBuildInfo(t *testing.T) {
|
||||||
|
// During `go test`, the Main module's path is the package under test.
|
||||||
|
// Its build info Version is typically "(devel)" — which we treat as
|
||||||
|
// unusable and fall back. Verify that branch: pass the current module
|
||||||
|
// path; expect the fallback rather than a literal "(devel)" leak.
|
||||||
|
got := ResolveModuleVersion("github.com/lukaszraczylo/oss-telemetry", "0.0.0-fallback")
|
||||||
|
if got == "(devel)" {
|
||||||
|
t.Fatalf("resolved to (devel) — should fall back to caller-supplied value")
|
||||||
|
}
|
||||||
|
if got == "v0.0.0-fallback" {
|
||||||
|
t.Fatalf("leading v not stripped from fallback path: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendForModule_FallbackPath(t *testing.T) {
|
||||||
|
resetState(t)
|
||||||
|
srv := newCaptureServer(t, http.StatusNoContent)
|
||||||
|
|
||||||
|
// Unknown modulePath → resolver returns the fallback, which validVersion
|
||||||
|
// must accept and Send forward.
|
||||||
|
SendForModule("tool", "nonexistent.example/none", "1.2.3")
|
||||||
|
Wait(2 * time.Second)
|
||||||
|
|
||||||
|
if srv.Hits() != 1 {
|
||||||
|
t.Fatalf("expected 1 hit, got %d", srv.Hits())
|
||||||
|
}
|
||||||
|
got, _ := srv.Last()
|
||||||
|
if got.Version != "1.2.3" {
|
||||||
|
t.Fatalf("expected wire version 1.2.3, got %q", got.Version)
|
||||||
|
}
|
||||||
|
if got.Project != "tool" {
|
||||||
|
t.Fatalf("expected project tool, got %q", got.Project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDisable_StopsSubsequentSends(t *testing.T) {
|
func TestDisable_StopsSubsequentSends(t *testing.T) {
|
||||||
resetState(t)
|
resetState(t)
|
||||||
srv := newCaptureServer(t, http.StatusNoContent)
|
srv := newCaptureServer(t, http.StatusNoContent)
|
||||||
@@ -329,9 +421,21 @@ func TestValidationHelpers(t *testing.T) {
|
|||||||
{"1.2.3", true},
|
{"1.2.3", true},
|
||||||
{"1.4.0-beta1", true},
|
{"1.4.0-beta1", true},
|
||||||
{"v1.0.0", true},
|
{"v1.0.0", true},
|
||||||
|
{"V1.0.0", true},
|
||||||
{"1.0.0+meta", true},
|
{"1.0.0+meta", true},
|
||||||
|
{"1.0.0-rc.1+build.7", true},
|
||||||
|
{"1", true},
|
||||||
|
{"1.2", true},
|
||||||
{"", false},
|
{"", false},
|
||||||
{"has space", false},
|
{"has space", false},
|
||||||
|
{"dev", false},
|
||||||
|
{"next", false},
|
||||||
|
{"v", false},
|
||||||
|
{"v.1.2", false},
|
||||||
|
{"1.2.3.4", false},
|
||||||
|
{"-1.2.3", false},
|
||||||
|
{"1.2.3-", false},
|
||||||
|
{"1.2.3+", false},
|
||||||
{strings.Repeat("1", 32), true},
|
{strings.Repeat("1", 32), true},
|
||||||
{strings.Repeat("1", 33), false},
|
{strings.Repeat("1", 33), false},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user