feat: add anonymous usage reporting, matching traefikoidc

Release builds send one anonymous "plugin loaded" ping per process, so
the project can see roughly how many installs exist and which versions
are in use. The payload is project name, version and timestamp. Nothing
else leaves the pod: no identifiers, no hostname, no configuration, no
provider names, no keys, no request data.

Three things keep it from firing when it should not. Builds from source
carry the devPluginVersion sentinel and stay silent, so developer
checkouts and every test run never phone home. The ping is sent only
after the plugin constructs successfully, so a rejected configuration is
never counted as an install. A sync.Once holds it to one per process,
because Traefik calls New once per route and oss-telemetry does not
deduplicate on the client side.

Operators of a release build opt out with DO_NOT_TRACK=1,
OSS_TELEMETRY_DISABLED=1, or TRAEFIK_LLMGATEWAY_DISABLE_TELEMETRY=1.
README.md documents the full payload and the opt-out; a plugin that
phones home without saying so in its own README would be indefensible.

oss-telemetry is the plugin's first non-stdlib RUNTIME dependency, which
has consequences for the Yaegi gate:

- vendor/ is no longer excluded from yaegi-check's GOPATH copy. It was
  excluded on the reasoning that it held only test-only testify and
  _test.go files are never interpreted. That reasoning no longer holds:
  leaving vendor out would make the interpreted copy fail to resolve an
  import the real plugin tree resolves fine, so the gate would stop
  representing production.

- yaegi-check now stamps a fake release version into its GOPATH copy so
  New's telemetry gate is PASSED rather than short-circuited, and the
  vendored package is actually called under the interpreter. Import
  resolution alone is not coverage; a cross-package call into interpreted
  third-party code is its own risk. Telemetry is suppressed by env vars
  set before anything is interpreted, so the gate never emits a real
  ping.

- A new assertion reads the version back through GET /admin/api/overview.
  Without it a silently failed stamp would leave the dev sentinel in
  place and the harness would still print OK while covering nothing.
  Verified by disabling the stamp and watching the gate fail.

shouldSendTelemetry is split out from sendStartupTelemetry so the
decision is table-testable without a network, since oss-telemetry's
endpoint is deliberately not overridable from consuming code.

Also brings the version-gated documentation up to date, since the same
README edit touches it. The latest release is v1.0.3, so the pre-1.0
experimental warning has met its own stated condition and is replaced by
a semver stability statement in README.md and dropped from
.traefik.yml's catalog summary. The catalog now serves the released tags
(verified: the v1.0.3 download endpoint returns 200), so the "not yet
published" and "analyzer has not been run" claims are corrected, the
catalog form of the static config is documented as usable, and
examples/kubernetes.yaml pins v1.0.3 instead of v0.1.0.
This commit is contained in:
2026-08-22 18:45:36 +01:00
parent 0346c3ed39
commit 9dc67dedbb
15 changed files with 938 additions and 54 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ import: github.com/lukaszraczylo/traefik-llmgateway
# integration suite). Required for both Traefik itself and the Plugin
# Catalog analyzer (piceus), which uses this identical mechanism.
basePkg: traefikllmgateway
summary: Multi-provider LLM gateway with groups, per-user keys, token/cost limits, MCP/A2A proxy. Experimental PoC until version 1.0 — not production ready.
summary: Multi-provider LLM gateway with groups, per-user keys, token/cost limits, MCP/A2A proxy.
testData:
providers:
openai:
+93 -31
View File
@@ -11,16 +11,15 @@ It runs as a [Yaegi](https://github.com/traefik/yaegi)-interpreted Go
module, the same way [`traefikoidc`](https://github.com/lukaszraczylo/traefikoidc)
does, configured per-middleware in Traefik's dynamic configuration.
> **⚠️ WARNING — experimental, proof of concept.**
> Until version 1.0 this project is an
> experiment in PoC development stage, not production ready. Configuration
> keys, endpoints, and behaviour can change between pre-1.0 releases without
> a deprecation period. Evaluate it, test it, break it — but do not put it
> in front of production traffic yet.
**Status:** stable at **v1.0**. Configuration keys, endpoints, and
behaviour now follow semver — breaking changes wait for a major release
instead of landing between patches, as they could before 1.0.
**Status:** feature-complete, all gates green (unit tests, `-race`,
`yaegi-check`, integration). Not yet published to the Traefik Plugin
Catalog — see [Development](#development) for what that needs.
Feature-complete, all gates green (unit tests, `-race`, `yaegi-check`,
integration), and published to the Traefik Plugin Catalog. One behaviour
worth knowing before you deploy: [streaming is delivered buffered rather
than token-by-token](#known-limitations) under the current Traefik + Yaegi
combination.
## What it is
@@ -88,12 +87,23 @@ reached when a request matches none of the plugin's routes and
### Static configuration
The plugin is not yet in the Traefik Plugin Catalog (see
[Development](#development)), so the catalog form of static configuration
(`experimental.plugins.llmgateway`) is not usable yet. Until then, load it
as a [`localPlugin`](https://doc.traefik.io/traefik/plugins/#defining-a-local-plugin),
The plugin is in the Traefik Plugin Catalog, so the catalog form of static
configuration works:
```yaml
experimental:
plugins:
llmgateway:
moduleName: github.com/lukaszraczylo/traefik-llmgateway
version: v1.0.3
```
You can also load it as a
[`localPlugin`](https://doc.traefik.io/traefik/plugins/#defining-a-local-plugin),
mounting this repository's source into the Traefik container at
`<plugins-local dir>/src/github.com/lukaszraczylo/traefik-llmgateway` — see
`<plugins-local dir>/src/github.com/lukaszraczylo/traefik-llmgateway`.
That form tracks a branch instead of a pinned release, which suits
development and any deployment that wants to follow `main` — see
[`examples/docker-compose.yml`](examples/docker-compose.yml) for a
working, tested setup that does exactly this:
@@ -105,15 +115,9 @@ experimental:
moduleName: github.com/lukaszraczylo/traefik-llmgateway
```
Once the plugin is published, the catalog form replaces this:
```yaml
experimental:
plugins:
llmgateway:
moduleName: github.com/lukaszraczylo/traefik-llmgateway
version: "v0.1.0" # substitute the tag you actually deploy
```
The catalog form above pins a released tag instead, which is the better
default for production: a pod restart cannot pick up a change you have not
deployed on purpose.
### Dynamic configuration
@@ -1269,6 +1273,58 @@ or in CI.
route. Neither line ever includes the presented API key — only the
identity it resolved to, never the key material itself.
## Anonymous usage reporting
Release builds send **one** anonymous "plugin loaded" ping per process, at
startup, so the project can see roughly how many installs exist and which
versions are in use.
**What is sent** — the whole payload:
```json
{ "project": "traefik-llmgateway", "version": "1.0.3", "ts": 1747782200 }
```
**What is never sent:** no identifiers, no hostname or IP, no machine
information, no configuration, no provider names or base URLs, no API
keys, no user or group names, no model names, and nothing derived from a
request. The gateway sends the three fields above and nothing else.
**When it does not happen at all:**
- **Builds from source stay silent.** The version constant is stamped only
at release time, so a checkout, a local build, and every test run carry
the `0.0.0-dev` sentinel and never ping.
- **Failed startups stay silent.** The ping is sent only after the plugin
constructs successfully, so a rejected configuration is never counted.
- Only once per process, however many routes use the plugin.
**How to turn it off** in a release build — set any one of these in
Traefik's environment:
```bash
DO_NOT_TRACK=1 # honours the do-not-track.dev convention
OSS_TELEMETRY_DISABLED=1
TRAEFIK_LLMGATEWAY_DISABLE_TELEMETRY=1 # this project only
```
In Kubernetes, add it to the Traefik deployment:
```yaml
env:
- name: DO_NOT_TRACK
value: "1"
```
**It cannot affect your gateway.** The call runs in its own goroutine with
a 2-second timeout, never blocks request handling or startup, never
retries, never persists anything, and swallows every error. If the
endpoint is unreachable or blocked by egress rules, nothing happens.
The client is [oss-telemetry](https://github.com/lukaszraczylo/oss-telemetry),
vendored in this repository, so you can read exactly what it does — it is
the plugin's only non-standard-library runtime dependency.
## Known limitations
- **Streaming is delivered buffered, not token-by-token, under the current
@@ -1476,18 +1532,24 @@ preference. [`resp.go`](resp.go) is the clearest example: a hand-rolled,
~400-line RESP2 client instead of `go-redis`, which proved unsuitable for
Yaegi interpretation.
### Catalog submission
### Catalog
[`.traefik.yml`](.traefik.yml) is in place, with a `basePkg` override
(`traefikllmgateway`) required because Traefik's Yaegi middleware loader
otherwise binds the interpreted package under
The plugin is listed, and the catalog serves the released tags.
[`.traefik.yml`](.traefik.yml) carries a `basePkg` override
(`traefikllmgateway`) because Traefik's Yaegi middleware loader otherwise
binds the interpreted package under
`strings.ReplaceAll(path.Base(import), "-", "_")` — `traefik_llmgateway`
— which does not match this package's actual `package traefikllmgateway`
declaration. This was discovered running the plugin in real Traefik for
the first time; see the comment in `.traefik.yml` itself. **The Plugin
Catalog's own analyzer (piceus) has not yet been run against this
repository** — whether it accepts the same `basePkg` override the same
way real Traefik does is unverified until actual submission.
the first time; see the comment in `.traefik.yml` itself. The catalog's
own analyzer (piceus) accepts the override the same way real Traefik
does, which the successful listing confirms.
Note that the catalog renders `.traefik.yml`'s description with a plain
CommonMark renderer: GitHub callout syntax (`> [!WARNING]`) and mermaid
blocks appear as literal text there, so keep that file's prose to plain
Markdown.
## License
+4 -4
View File
@@ -198,16 +198,16 @@ spec:
# field names against the chart version you deploy. Two things are
# required beyond a stock install:
#
# 1. Load the plugin. Either the catalog form (once this plugin is listed
# in the Traefik Plugin Catalog, see ../.traefik.yml) or localPlugins,
# mounting this repository's source into the Traefik pod the same way
# 1. Load the plugin. Either the catalog form, which pins a released tag
# and is the better default for production, or localPlugins, mounting
# this repository's source into the Traefik pod the same way
# ../examples/docker-compose.yml does for its local demo:
#
# experimental:
# plugins:
# llmgateway:
# moduleName: "github.com/lukaszraczylo/traefik-llmgateway"
# version: "v0.1.0"
# version: "v1.0.3"
#
# 2. Every "env:VAR_NAME" secret this Middleware references as env vars,
# sourced from Secrets — never inlined into the Middleware CR above.
+4 -1
View File
@@ -2,6 +2,9 @@ module github.com/lukaszraczylo/traefik-llmgateway
go 1.22
require github.com/stretchr/testify v1.12.1
require (
github.com/lukaszraczylo/oss-telemetry v0.2.3
github.com/stretchr/testify v1.12.1
)
require go.yaml.in/yaml/v3 v3.0.5 // indirect
+2
View File
@@ -1,3 +1,5 @@
github.com/lukaszraczylo/oss-telemetry v0.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+57
View File
@@ -10,6 +10,9 @@ import (
"net/http"
"os"
"runtime"
"sync"
telemetry "github.com/lukaszraczylo/oss-telemetry"
)
// Config is the plugin's dynamic configuration, populated by Traefik from
@@ -479,6 +482,56 @@ type Gateway struct {
name string
}
// telemetryStartupOnce keeps the anonymous "plugin loaded" ping to one per
// process. Traefik calls New once per ROUTE that uses this plugin, and a
// busy ingress can have many; oss-telemetry does not deduplicate on the
// client side (the server does), so the gate belongs here.
var telemetryStartupOnce sync.Once
// sendStartupTelemetry fires at most one anonymous "plugin loaded" ping
// per process, matching traefikoidc's behaviour.
//
// What leaves the pod: project name, version and a timestamp. No
// identifiers, no config, no provider names, no keys, no request data.
// The call never blocks (it runs in oss-telemetry's own goroutine), never
// panics, never retries and never returns an error, so it cannot affect
// request handling or delay Traefik's startup.
//
// Silent unless the build was stamped: an unstamped tree still carries
// devPluginVersion, so developers and operators building from a checkout
// never phone home. Operators of a RELEASE build opt out with any of
// DO_NOT_TRACK=1, OSS_TELEMETRY_DISABLED=1, or
// TRAEFIK_LLMGATEWAY_DISABLE_TELEMETRY=1 (oss-telemetry reads all three;
// the last is this project's name uppercased with dashes replaced). This
// is documented in README.md — a plugin that phones home without saying
// so in its own README would be indefensible.
func sendStartupTelemetry() {
telemetryStartupOnce.Do(func() {
if !shouldSendTelemetry(pluginVersion) {
return
}
telemetry.Send(telemetryProjectName, pluginVersion)
})
}
// telemetryProjectName is the project identifier reported in the ping. It
// matches the repository name, and doubles as the prefix oss-telemetry
// derives its per-project opt-out variable from
// (TRAEFIK_LLMGATEWAY_DISABLE_TELEMETRY).
const telemetryProjectName = "traefik-llmgateway"
// shouldSendTelemetry reports whether a build stamped with version may
// emit a startup ping. Only release-stamped builds may: an empty or
// dev-sentinel version means nothing stamped this tree, so it is a
// checkout, a test run or a local build, none of which should phone home.
//
// Split out from sendStartupTelemetry so the decision is testable without
// a network: the send itself goes through oss-telemetry, whose endpoint
// is deliberately not overridable from consuming code.
func shouldSendTelemetry(version string) bool {
return version != "" && version != devPluginVersion
}
// New creates the middleware. NOTE: no tail call — Yaegi zeroes
// multi-value tail-call returns across the reflect boundary.
func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
@@ -486,6 +539,10 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
if err != nil {
return nil, err
}
// Deliberately AFTER construction succeeds: a config the gateway
// rejects never loaded a working plugin, so counting it as an install
// would inflate the numbers with failures.
sendStartupTelemetry()
return g, nil
}
+72
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
)
@@ -853,3 +854,74 @@ func TestNewGateway_SetsPricingWarnFn(t *testing.T) {
t.Fatalf("want the pricing warning routed through the gateway's own logf, got %q", buf.String())
}
}
// TestShouldSendTelemetry pins the guard that keeps unstamped builds from
// phoning home. Every developer checkout and every CI run carries
// devPluginVersion, so the false cases are the state this code is in
// nearly all of the time: if the guard inverts, `go test` itself starts
// emitting pings.
func TestShouldSendTelemetry(t *testing.T) {
tests := []struct {
name string
version string
want bool
}{
{"dev sentinel stays silent", devPluginVersion, false},
{"empty version stays silent", "", false},
{"stamped release sends", "0.2.38", true},
{"stamped prerelease sends", "1.0.0-rc1", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldSendTelemetry(tt.version); got != tt.want {
t.Errorf("shouldSendTelemetry(%q) = %v, want %v", tt.version, got, tt.want)
}
})
}
}
// TestPluginVersion_UnstampedInSourceTree guards the committed state: the
// repository must never carry a stamped version, or a developer's own
// build would phone home. workflow-prepare.sh stamps a COPY at release
// time; if a stamped value is ever committed by mistake, this fails.
func TestPluginVersion_UnstampedInSourceTree(t *testing.T) {
if pluginVersion != devPluginVersion {
t.Errorf("committed pluginVersion = %q, want the %q sentinel — a stamped version must never be committed", pluginVersion, devPluginVersion)
}
}
// TestNew_DoesNotPanicOnRepeatedConstruction covers the telemetry
// sync.Once gate under the real call pattern: Traefik constructs this
// middleware once PER ROUTE, so New runs many times in one process.
// Construction must stay safe and cheap on every later call.
func TestNew_DoesNotPanicOnRepeatedConstruction(t *testing.T) {
t.Setenv("DO_NOT_TRACK", "1")
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
for i := 0; i < 5; i++ {
h, err := New(context.Background(), http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), cfg, "llmgw")
if err != nil {
t.Fatalf("New #%d: %v", i, err)
}
if h == nil {
t.Fatalf("New #%d returned a nil handler", i)
}
}
}
// TestSendStartupTelemetry_OnceAcrossConcurrentCalls pins the sync.Once
// gate itself: Traefik can construct several routes concurrently at
// startup, and oss-telemetry does not deduplicate client-side, so a
// missing gate would emit one ping per route.
func TestSendStartupTelemetry_OnceAcrossConcurrentCalls(t *testing.T) {
t.Setenv("DO_NOT_TRACK", "1")
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
sendStartupTelemetry()
}()
}
wg.Wait() // -race proves the Once is the only synchronisation needed
}
+120 -14
View File
@@ -16,10 +16,17 @@
//
// This module is intentionally separate from the plugin module
// (llmgw-yaegi-check, not github.com/lukaszraczylo/traefik-llmgateway):
// the plugin module must stay free of any non-stdlib dependency, since
// Yaegi interprets it with only the Go standard library available: a
// third-party import in the plugin module's own go.mod would break under
// interpretation even though `go build` never notices.
// this harness's own dependencies (yaegi itself) must never become the
// plugin's, and nothing here may be reachable from interpreted code.
//
// The plugin module carries exactly ONE non-stdlib runtime dependency,
// github.com/lukaszraczylo/oss-telemetry, which is vendored and therefore
// interpretable — Yaegi resolves it out of vendor/ in the GOPATH copy,
// the same way Traefik resolves it from the cloned plugin tree. That is
// why vendor/ is no longer excluded below. Every OTHER runtime import
// must stay stdlib-only: an unvendored third-party import breaks under
// interpretation even though `go build` never notices, and this harness
// is what catches it.
package main
import (
@@ -99,15 +106,23 @@ const (
)
// excludedTopLevelDirs lists repo-root directories the GOPATH copy must
// never include: build tooling, integration fixtures, planning docs, VCS
// metadata, and vendor (task-8's test-only testify dependency — _test.go
// files are never interpreted by Yaegi, so vendor/ has nothing this check
// needs, and copying its "go.yaml.in"-style nested module dirs into a
// module-less GOPATH tree would otherwise confuse Yaegi's own import
// resolution) have nothing to do with the plugin package Yaegi imports.
// webui/ (Vue admin-panel task) is excluded for the same reason vendor/
// is: its own go.mod already walls it off from the repo root's `go
// build`/`vet`/`test ./...`, and its node_modules tree — hundreds of MB,
// never include: build tooling, integration fixtures, planning docs and
// VCS metadata have nothing to do with the plugin package Yaegi imports.
//
// vendor/ USED TO be excluded here, on the reasoning that it held only
// the test-only testify dependency and _test.go files are never
// interpreted. That stopped being true when the plugin took on
// oss-telemetry as a runtime dependency: leaving vendor/ out would make
// the interpreted copy unable to resolve an import the real plugin tree
// resolves fine, so this harness would fail on code that works in
// production — or, worse, quietly stop representing it. Copying the whole
// vendor tree, nested "go.yaml.in"-style module dirs included, resolves
// correctly under yaegi v0.16.1 (verified: the gate passes with the
// telemetry import live and reached).
//
// webui/ (Vue admin-panel task) is still excluded: its own go.mod already
// walls it off from the repo root's `go build`/`vet`/`test ./...`, and
// its node_modules tree — hundreds of MB,
// including a stray bundled .go file
// (node_modules/flatted/golang/pkg/flatted.go) — would otherwise both
// balloon copyRepoSource's wall time and risk Yaegi tripping over code
@@ -120,7 +135,6 @@ var excludedTopLevelDirs = map[string]bool{
".superpowers": true,
"docs": true,
".git": true,
"vendor": true,
"webui": true,
}
@@ -163,6 +177,24 @@ func run() error {
return fmt.Errorf("copy repo source into GOPATH: %w", err)
}
// Suppress telemetry BEFORE anything is interpreted, and keep it
// suppressed for the whole run. stampReleaseVersion below makes the
// interpreted copy look like a stamped release build so that New's
// telemetry call is actually REACHED under Yaegi; without these env
// vars that would fire a real ping at the public ingest endpoint from
// every CI run and every developer's `make yaegi-check`. Both names
// are set because either one alone suppresses, so a rename upstream
// cannot silently re-enable it.
if err = os.Setenv("DO_NOT_TRACK", "1"); err != nil {
return fmt.Errorf("set DO_NOT_TRACK: %w", err)
}
if err = os.Setenv("OSS_TELEMETRY_DISABLED", "1"); err != nil {
return fmt.Errorf("set OSS_TELEMETRY_DISABLED: %w", err)
}
if err = stampReleaseVersion(pkgDir); err != nil {
return fmt.Errorf("stamp a release version into the interpreted copy: %w", err)
}
pkgName, err := readPackageName(pkgDir)
if err != nil {
return fmt.Errorf("read plugin package name: %w", err)
@@ -350,6 +382,45 @@ const mcpProbeOversizeBytes = 11 << 20
// harness exists to catch, and which no compiled test can see.
const mcpTooLargeWantMessage = "response too large"
// stampedCheckVersion is the fake release version stampReleaseVersion
// writes into the interpreted copy. It only has to differ from
// devPluginVersion; nothing asserts its value.
const stampedCheckVersion = "9.9.9-yaegicheck"
// stampReleaseVersion rewrites pluginVersion in the GOPATH COPY of
// version.go (never the repo's own file) so the copy no longer carries
// the dev sentinel.
//
// Without this, New's telemetry gate returns before ever calling into the
// vendored oss-telemetry package, and this harness would report OK while
// leaving the plugin's only non-stdlib runtime import unexercised under
// the interpreter — precisely the blind spot yaegi-check exists to close.
// Import resolution alone is not enough: a cross-package call into
// interpreted third-party code is its own risk under Yaegi.
//
// Telemetry stays suppressed by the DO_NOT_TRACK/OSS_TELEMETRY_DISABLED
// env vars the caller sets first, so Send is entered and returns without
// touching the network. The HTTP path beyond that env check is plain
// net/http, already exercised elsewhere in this harness.
func stampReleaseVersion(pkgDir string) error {
path := filepath.Join(pkgDir, "version.go")
data, err := os.ReadFile(path) //nolint:gosec // a temp dir this harness just created
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
pattern := regexp.MustCompile(`(pluginVersion\s*=\s*")[^"]*(")`)
if !pattern.Match(data) {
return fmt.Errorf("no pluginVersion constant found in %s — has version.go been renamed?", path)
}
stamped := pattern.ReplaceAll(data, []byte(`${1}`+stampedCheckVersion+`${2}`))
// pkgDir is a temp dir run() created itself, and the filename is a
// constant — no external input reaches this path.
if err := os.WriteFile(path, stamped, 0o600); err != nil { //nolint:gosec // G703: path is filepath.Join(<temp dir we created>, "version.go")
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
// readBuiltinContextTokens reads repoRoot/pricing_data_gen.go and
// extracts modelID's own ContextTokens value directly out of the
// generated source (review fix, SHOULD-6) — rather than hardcoding a
@@ -457,6 +528,10 @@ func exerciseHandler(handler http.Handler, builtinContextTokens int) error {
return err
}
if err := exerciseStampedVersion(handler); err != nil {
return err
}
unauthedReq := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
unauthedRec := httptest.NewRecorder()
handler.ServeHTTP(unauthedRec, unauthedReq)
@@ -495,6 +570,37 @@ func exerciseHandler(handler http.Handler, builtinContextTokens int) error {
// a compiled concrete value's method set in Yaegi. matchesSentinel now
// uses real errors.Is instead (its own doc comment in limits.go has the
// full account); this harness is what caught the difference.
// exerciseStampedVersion asserts the interpreted plugin reports
// stampedCheckVersion in GET /admin/api/overview.
//
// This is the proof that stampReleaseVersion actually landed, and so the
// proof that New's telemetry gate was PASSED rather than short-circuited:
// the gate compares this same pluginVersion constant against
// devPluginVersion, so a version of stampedCheckVersion here means the
// interpreted code went on to call into the vendored oss-telemetry
// package. Without this assertion a silently failed stamp would leave the
// dev sentinel in place and the harness would still print OK, claiming
// coverage it does not have.
func exerciseStampedVersion(handler http.Handler) error {
req := httptest.NewRequest(http.MethodGet, "/admin/api/overview", nil)
req.Header.Set("Authorization", "Bearer "+attemptAccountingAdminAPIKey)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
return fmt.Errorf("GET /admin/api/overview: status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var overview struct {
Version string `json:"version"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &overview); err != nil {
return fmt.Errorf("decode GET /admin/api/overview body: %w", err)
}
if overview.Version != stampedCheckVersion {
return fmt.Errorf("interpreted plugin reports version %q, want %q — stampReleaseVersion did not take effect, so New's telemetry gate short-circuited on the dev sentinel and the vendored oss-telemetry call was never exercised under Yaegi", overview.Version, stampedCheckVersion)
}
return nil
}
// exerciseFederatedTooLarge drives POST /mcp (federation's tools/call
// path) against mcpProbeUpstream, whose response deliberately exceeds
// mcpBackendCallResponseMaxBytes, and asserts the interpreted plugin
@@ -0,0 +1 @@
.docs
+36
View File
@@ -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
+42
View File
@@ -0,0 +1,42 @@
# Configuration for lukaszraczylo/semver-generator.
# Reference: https://github.com/lukaszraczylo/semver-generator
#
# Word matching is fuzzy + case-insensitive. The keywords below mirror the
# Conventional Commits prefixes used in this repo's git history. Same pattern
# as github.com/lukaszraczylo/go-telegram/.semver.yaml.
version: 1
# Respect existing v* tags as the version baseline. semver-generator finds
# the highest existing tag and bumps from there. With no tags yet, the first
# release computes from the empty base.
force:
existing: true
# Skip merge commits and machine-generated traffic that would otherwise
# spuriously bump the version.
blacklist:
- "Merge branch"
- "Merge pull request"
- "Merge remote-tracking branch"
- "go mod tidy"
wording:
patch:
- "fix"
- "chore"
- "docs"
- "test"
- "style"
- "refactor"
- "build"
- "ci"
- "perf"
minor:
- "feat"
major:
# Match only the canonical Conventional Commits trailer. The bare word
# "breaking" is too greedy under semver-generator's fuzzy match — it
# triggers on substrings inside a commit body and wrongly produces a
# major bump.
- "BREAKING CHANGE"
+122
View File
@@ -0,0 +1,122 @@
# 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.
## Disabling telemetry
If you ship a binary that imports this library, link your users to this
section (`https://github.com/lukaszraczylo/oss-telemetry#disabling-telemetry`)
so they can find the opt-out paths.
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.
+367
View File
@@ -0,0 +1,367 @@
// 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"
"runtime/debug"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
const (
defaultEndpoint = "https://oss.raczylo.com/v1/ping"
httpTimeout = 2 * time.Second
maxProjectLen = 64
maxVersionLen = 32
)
// Yaegi note: this package is consumed by the traefikoidc Traefik plugin, which
// Traefik interprets with Yaegi (it vendors and interprets dependency source).
// It therefore avoids generic stdlib types (atomic.Pointer[T], atomic.Bool) and
// range-over-int (Go 1.22), which some Traefik/Yaegi runtimes cannot interpret.
// Endpoint mutation uses a mutex-guarded string; the disabled flag uses the
// function-based sync/atomic int32 API (atomic.LoadInt32/StoreInt32).
var (
// endpointURL holds the ingest URL. Production code never mutates it; the
// setter exists only so the test suite can retarget it at httptest servers
// while goroutines started by Send are still in flight.
endpointMu sync.RWMutex
endpointURL = defaultEndpoint
disabled int32 // 0 = enabled, 1 = disabled; accessed via sync/atomic only
inflight sync.WaitGroup
client = &http.Client{Timeout: httpTimeout}
)
func currentEndpoint() string {
endpointMu.RLock()
defer endpointMu.RUnlock()
return endpointURL
}
func setEndpointURL(u string) {
endpointMu.Lock()
endpointURL = u
endpointMu.Unlock()
}
// 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.
//
// 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;
// the server is responsible for deduplication.
func Send(project, version string) {
if atomic.LoadInt32(&disabled) != 0 {
return
}
if isDisabledByEnv(project) {
return
}
if !validProject(project) || !validVersion(version) {
return
}
canonical := normalizeVersion(version)
inflight.Add(1)
go func() {
defer inflight.Done()
defer func() { _ = recover() }()
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.
// Idempotent and safe to call from any goroutine.
func Disable() {
atomic.StoreInt32(&disabled, 1)
}
// 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, currentEndpoint(), 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 := 0; i < n; i++ {
c := p[i]
switch {
case c >= 'a' && c <= 'z',
c >= '0' && c <= '9',
c == '-':
default:
return false
}
}
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 {
n := len(v)
if n == 0 || n > maxVersionLen {
return false
}
if v[0] == 'v' || v[0] == 'V' {
v = v[1:]
}
if len(v) == 0 {
return false
}
return checkSemverShape(v)
}
// 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
}
}
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 {
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 := 0; i < len(project); i++ {
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
}
+3
View File
@@ -1,3 +1,6 @@
# github.com/lukaszraczylo/oss-telemetry v0.2.3
## explicit; go 1.22
github.com/lukaszraczylo/oss-telemetry
# github.com/stretchr/testify v1.12.1
## explicit; go 1.17
github.com/stretchr/testify/assert
+14 -3
View File
@@ -1,5 +1,12 @@
package traefikllmgateway
// devPluginVersion is the placeholder carried by source-tree, local and
// test builds. Anonymous usage telemetry is suppressed while pluginVersion
// still reports this sentinel, so only stamped release builds emit a
// "plugin loaded" ping — a developer running the test suite, or an
// operator building from a checkout, never phones home.
const devPluginVersion = "0.0.0-dev"
// pluginVersion is the released version of this plugin. It is stamped at
// release time by ./workflow-prepare.sh (invoked by the shared go-release
// workflow before GoReleaser builds and tags), which rewrites the string
@@ -9,7 +16,11 @@ package traefikllmgateway
// resolved from build info at runtime (debug.ReadBuildInfo sees Traefik's
// build graph, not the interpreted plugin). This build-stamped constant is
// therefore the single source of truth for the version surfaced read-only
// in GET /admin/api/overview (spec §4, v0.2). Local/dev/test builds carry
// the "0.0.0-dev" sentinel below, since nothing has stamped them. Not read
// from anywhere else in the package.
// in GET /admin/api/overview (spec §4, v0.2) and reported by anonymous
// usage telemetry (llmgateway.go). Local/dev/test builds carry the
// devPluginVersion sentinel above, since nothing has stamped them.
//
// The literal below must stay byte-identical to devPluginVersion:
// workflow-prepare.sh rewrites THIS line by name, and telemetry compares
// the two constants to decide whether the build was stamped.
const pluginVersion = "0.0.0-dev"