diff --git a/.traefik.yml b/.traefik.yml index 5880898..ba88b2e 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -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: diff --git a/README.md b/README.md index 288b864..880deaf 100644 --- a/README.md +++ b/README.md @@ -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 -`/src/github.com/lukaszraczylo/traefik-llmgateway` — see +`/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 diff --git a/examples/kubernetes.yaml b/examples/kubernetes.yaml index 65be24b..4f17733 100644 --- a/examples/kubernetes.yaml +++ b/examples/kubernetes.yaml @@ -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. diff --git a/go.mod b/go.mod index e6e41d3..96e17f5 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index c233683..f582bd9 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/llmgateway.go b/llmgateway.go index 8c4a859..91f513d 100644 --- a/llmgateway.go +++ b/llmgateway.go @@ -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 } diff --git a/llmgateway_test.go b/llmgateway_test.go index 3b7a830..a41cbaa 100644 --- a/llmgateway_test.go +++ b/llmgateway_test.go @@ -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 +} diff --git a/tools/yaegi-check/main.go b/tools/yaegi-check/main.go index 1261538..ab508c1 100644 --- a/tools/yaegi-check/main.go +++ b/tools/yaegi-check/main.go @@ -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(, "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 diff --git a/vendor/github.com/lukaszraczylo/oss-telemetry/.gitignore b/vendor/github.com/lukaszraczylo/oss-telemetry/.gitignore new file mode 100644 index 0000000..d18a6af --- /dev/null +++ b/vendor/github.com/lukaszraczylo/oss-telemetry/.gitignore @@ -0,0 +1 @@ +.docs diff --git a/vendor/github.com/lukaszraczylo/oss-telemetry/.golangci.yml b/vendor/github.com/lukaszraczylo/oss-telemetry/.golangci.yml new file mode 100644 index 0000000..1c8ff72 --- /dev/null +++ b/vendor/github.com/lukaszraczylo/oss-telemetry/.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/vendor/github.com/lukaszraczylo/oss-telemetry/.semver.yaml b/vendor/github.com/lukaszraczylo/oss-telemetry/.semver.yaml new file mode 100644 index 0000000..595cefc --- /dev/null +++ b/vendor/github.com/lukaszraczylo/oss-telemetry/.semver.yaml @@ -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" diff --git a/vendor/github.com/lukaszraczylo/oss-telemetry/README.md b/vendor/github.com/lukaszraczylo/oss-telemetry/README.md new file mode 100644 index 0000000..c23f703 --- /dev/null +++ b/vendor/github.com/lukaszraczylo/oss-telemetry/README.md @@ -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 | `_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/vendor/github.com/lukaszraczylo/oss-telemetry/telemetry.go b/vendor/github.com/lukaszraczylo/oss-telemetry/telemetry.go new file mode 100644 index 0000000..84b04ea --- /dev/null +++ b/vendor/github.com/lukaszraczylo/oss-telemetry/telemetry.go @@ -0,0 +1,367 @@ +// 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" + "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 "_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 +} diff --git a/vendor/modules.txt b/vendor/modules.txt index b2c1064..542dcf6 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -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 diff --git a/version.go b/version.go index c0e5842..ae7aa49 100644 --- a/version.go +++ b/version.go @@ -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"