Merge pull request #1148 from J3vb/claude/review-phase-completion-PBExk

Complete Phase B & C: OTel telemetry, Wazero plugins, and plugin admin API
This commit is contained in:
J3vb
2026-04-07 08:24:53 +02:00
committed by GitHub
12 changed files with 1240 additions and 257 deletions
+37 -2
View File
@@ -33,9 +33,39 @@ class PluginBridge {
private frames = new Map<number, HTMLIFrameElement>();
private listeners = new Set<Listener>();
private themeVars: Record<string, string> = {};
private hostOrigin: string;
constructor() {
window.addEventListener("message", this.onMessage);
// Plugin iframes are served from /api/v1/plugins/... on the same origin
// as the host page, so postMessage targets that origin explicitly. Using
// "*" as the target origin is unsafe — any frame the user navigates to
// would receive host messages. window.location.origin is undefined in
// some test runners (jsdom prior to 16); fall back to "/" which still
// restricts to same-origin under the strict postMessage matching rules.
this.hostOrigin =
typeof window !== "undefined" && window.location && window.location.origin
? window.location.origin
: "/";
if (typeof window !== "undefined") {
window.addEventListener("message", this.onMessage);
}
}
/**
* destroy unhooks the global message listener and clears all mounted
* frames. Intended for tests that create disposable bridge instances; the
* exported `pluginBridge` singleton lives for the lifetime of the page and
* does not need explicit teardown.
*/
destroy(): void {
if (typeof window !== "undefined") {
window.removeEventListener("message", this.onMessage);
}
for (const frame of this.frames.values()) {
frame.remove();
}
this.frames.clear();
this.listeners.clear();
}
/** Replace the theme variables broadcast to plugin iframes. */
@@ -80,9 +110,14 @@ class PluginBridge {
}
private postToFrame(pluginId: number, frame: HTMLIFrameElement, msg: { type: string; payload: unknown }): void {
// Restrict the postMessage target origin to the host page origin so a
// navigated-away iframe (or one whose contentWindow has been swapped)
// cannot receive host messages intended for a sandboxed plugin. The
// plugin asset endpoint is same-origin with the host page, so this
// matches every legitimate plugin iframe.
frame.contentWindow?.postMessage(
{ source: HOST_ORIGIN_PREFIX, pluginId, ...msg },
"*",
this.hostOrigin,
);
}
+10 -3
View File
@@ -3,8 +3,11 @@ import { resolve } from "path";
import solidPlugin from "vite-plugin-solid";
export default defineConfig({
// The Solid plugin must be applied here in addition to vite.config.ts so
// Vitest can transform `.tsx` test files under src/components/solid/.
// Without it, JSX in component tests is parsed as TypeScript and fails on
// the angle brackets.
plugins: [
// Transform Solid JSX/TSX for component tests under src/components/solid/.
solidPlugin({
include: ["src/components/solid/**/*.{ts,tsx,js,jsx}"],
}),
@@ -20,10 +23,14 @@ export default defineConfig({
},
test: {
environment: "jsdom",
// Both the legacy `tests/**/*.test.ts` suite and component-local
// `src/**/*.test.{ts,tsx}` files are picked up. The latter is required
// for Phase B Step 6 Solid components, whose tests live alongside the
// component file (see src/components/solid/README.md).
include: [
"tests/**/*.test.ts",
// Solid component tests live next to the source they test.
"src/components/solid/**/*.test.tsx",
"src/**/*.test.ts",
"src/**/*.test.tsx",
],
coverage: {
provider: "v8",
+52 -15
View File
@@ -131,15 +131,31 @@ The session landed:
Still TODO locally:
- [x] Add the OTel modules to `go.mod`:
otel v1.43.0, sdk v1.43.0, exporters/prometheus v0.65.0,
exporters/otlp/otlptrace/otlptracegrpc v1.43.0,
otelhttp v0.67.0 (used instead of unavailable otelchi).
- [x] Add the OTel modules to `go.mod`: otel v1.43.0, sdk v1.43.0,
exporters/prometheus v0.65.0,
exporters/otlp/otlptrace/otlptracegrpc v1.43.0, and
`go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`
v0.67.0 (otelhttp replaces the unmaintained otelchi wrapper
referenced by the original plan; otelhttp is upstream-supported
and wraps any `http.Handler` including a Chi router).
- [x] Replace the placeholder body of `telemetry/telemetry_otel.go`'s
`Init` with real SDK wiring: prometheus + otlp branches, bridge
types for Tracer/Span/Meter/Counter/Histogram/Gauge, otelhttp
middleware.
- [x] Build with `-tags otel` — passes. Full 5-tag matrix green.
`Init` with the real tracer + meter provider construction. The
tagged build wires an OTel Prometheus exporter (pull), an OTLP/gRPC
trace exporter when `exporter=otlp` (with `OTLPInsecure` opt-in for
plaintext gRPC), `otelhttp.NewHandler` as the HTTP middleware, and
a real provider that re-binds `AppMetrics` instruments via
`resetAppMetricsForInit`. Tests in
`Server/telemetry/telemetry_otel_test.go`
(`TestOtelInitPrometheusExporter`, `TestOtelTracerRecordsSpan`,
`TestOtelHistogramRecordsSeconds`, `TestOtelShutdownIdempotent`,
`TestOtelConvertAttrsHandlesUnsignedInts`,
`TestOtelConvertAttrsUint64OverflowFallsBackToString`,
`TestOtelAppMetricsRebindsAfterInit`) run under
`go test -tags otel ./telemetry/...`.
- [x] Build with `-tags otel` — passes. Full 4-tag matrix green
(default, otel, wazero, otel+wazero) against Go 1.25.1.
- [ ] Add a CI job that exercises `go build -tags otel ./...` and
`go test -tags otel ./telemetry/...`.
- [x] Add spans to the remaining service-layer entry points
(`DMService`, `VoiceService`, `InviteService`, `ModerationService`,
`BlockService`, `UserService`) — landed in Pass 3, one entrypoint
@@ -184,15 +200,36 @@ The session landed:
Still TODO locally:
- [x] Add wazero to `go.mod`: v1.11.0 landed.
- [x] Add `github.com/tetratelabs/wazero v1.11.0` to `go.mod`.
- [x] Replace the placeholder body in `Server/plugin/sandbox_wazero.go`
with real wazero runtime: lazy ensureRuntimeLocked, WASI host
instantiation, CompileModule + InstantiateModule, JSON-ABI command
dispatch, listExportedCommands via list_commands export.
with real wazero runtime construction. The tagged build owns a
shared `wazero.Runtime` created in `platformInit` (with the
configured `MaxMemoryMB` translated to `WithMemoryLimitPages` and
WASI preview-1 imports pre-instantiated), compiles + instantiates
each plugin's `.wasm` entrypoint in `activateWithRuntime`, and
tears modules + runtime down in `platformDeactivate` / `Close`.
The host-guest command ABI is JSON-over-linear-memory:
`allocate(size)` / `command_dispatch(ptr,len) → (ptr,len)` /
`deallocate(ptr,len)`, with optional `list_commands` for command
auto-registration. Tests in `Server/plugin/sandbox_wazero_test.go`
(`TestWazeroRegistryCreatesRuntime`,
`TestWazeroActivateCompilesModule`,
`TestWazeroDispatchCommandMissingExport`,
`TestWazeroCloseTearsDownRuntime`,
`TestWazeroInvalidWASMFailsActivation`,
`TestWazeroDisablePluginFreesModule`) run under
`go test -tags wazero ./plugin/...` using a 41-byte embedded WASM
fixture for the smoke tests.
- [x] Add a precompiled `Server/plugin/examples/hello/hello.wasm`
(925 KiB) built with TinyGo 0.40.1 + Go 1.25.3 + Binaryen
wasm-opt 129. Source in `examples/hello/main.go`; exports:
`allocate`, `deallocate`, `list_commands`, `command_dispatch`,
`on_event`.
- [x] Replace JSON-only manifest parsing with TOML support behind the
`wazero` build tag. Added BurntSushi/toml v1.6.0, manifest_toml.go
(wazero) + manifest_nottoml.go (!wazero), loader.go prefers plugin.toml
then falls back to plugin.json.
`wazero` build tag. Added `github.com/BurntSushi/toml` v1.6.0,
`manifest_toml.go` (wazero) + `manifest_nottoml.go` (!wazero);
`loader.go` prefers `plugin.toml` and falls back to
`plugin.json`.
- [x] Wire `Server/plugin/host_events.go` into the WS pub/sub hub.
Landed: `EventSink.SetBroadcaster`/`Emit` added; hub gains
`SetPluginEventSink`; `deliverBroadcast` calls `sink.Dispatch`
+52 -1
View File
@@ -10,6 +10,7 @@ import (
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/plugin"
@@ -57,13 +58,25 @@ func (h *PluginAdminHandler) install(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid multipart upload", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("plugin")
file, header, err := r.FormFile("plugin")
if err != nil {
http.Error(w, "missing 'plugin' file part", http.StatusBadRequest)
return
}
defer file.Close() //nolint:errcheck
// Reject obviously-wrong uploads early. The real defence is the zip
// reader inside InstallFromZip (content-type is client-supplied and must
// never be trusted for authorisation), but rejecting non-zip MIME types
// here returns a cleaner 400 than a "not a valid zip" error from deep
// inside the registry.
if header != nil {
if ct := header.Header.Get("Content-Type"); ct != "" && !isZipContentType(ct) {
http.Error(w, "plugin upload must be a .zip archive", http.StatusUnsupportedMediaType)
return
}
}
// Read the entire zip into memory — InstallFromZip needs an io.ReaderAt
// for archive/zip and the cap is small enough to be safe.
body, err := io.ReadAll(io.LimitReader(file, maxPluginUploadBytes+1))
@@ -75,6 +88,13 @@ func (h *PluginAdminHandler) install(w http.ResponseWriter, r *http.Request) {
http.Error(w, "plugin upload too large", http.StatusRequestEntityTooLarge)
return
}
// Magic-byte check: a real .zip starts with "PK\x03\x04" (local file
// header) or "PK\x05\x06" (empty archive). Anything else is definitively
// not a zip regardless of what the client labelled it.
if !hasZipMagic(body) {
http.Error(w, "plugin upload is not a valid zip archive", http.StatusBadRequest)
return
}
name, err := h.registry.InstallFromZip(r.Context(), body)
if err != nil {
slog.Error("plugin install failed", "error", err)
@@ -153,6 +173,37 @@ func (h *PluginAdminHandler) uninstall(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// isZipContentType reports whether ct looks like a zip MIME type. Both the
// IANA-registered application/zip and the legacy application/x-zip-compressed
// (used by some Windows clients) are accepted. The comparison is case-
// insensitive and strips any parameters after a semicolon.
func isZipContentType(ct string) bool {
for i := 0; i < len(ct); i++ {
if ct[i] == ';' {
ct = ct[:i]
break
}
}
switch strings.ToLower(strings.TrimSpace(ct)) {
case "application/zip", "application/x-zip-compressed", "application/octet-stream":
return true
}
return false
}
// hasZipMagic reports whether b begins with the PK signature used by every
// .zip archive. Empty archives use 0x50,0x4b,0x05,0x06; non-empty archives
// start with a local file header 0x50,0x4b,0x03,0x04. Both are accepted.
func hasZipMagic(b []byte) bool {
if len(b) < 4 {
return false
}
if b[0] != 'P' || b[1] != 'K' {
return false
}
return (b[2] == 0x03 && b[3] == 0x04) || (b[2] == 0x05 && b[3] == 0x06)
}
func parsePluginID(w http.ResponseWriter, r *http.Request) (int64, bool) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
+241
View File
@@ -0,0 +1,241 @@
// Phase C Step 9 — PluginAdminHandler tests.
//
// The handler is covered at the HTTP boundary so the fixtures do not depend
// on the Wazero runtime. A nil Registry exercises the "plugin runtime
// disabled" branch; a real Registry wired against a MemStore exercises the
// happy path.
package api
import (
"archive/zip"
"bytes"
"context"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/owncord/server/plugin"
"github.com/owncord/server/store"
)
func TestPluginsHandlerListEmptyWhenRegistryNil(t *testing.T) {
h := NewPluginAdminHandler(nil, nil)
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200", rec.Code)
}
if strings.TrimSpace(rec.Body.String()) != "[]" {
t.Fatalf("expected empty JSON array, got %q", rec.Body.String())
}
}
func TestPluginsHandlerInstallRejectsWhenRegistryNil(t *testing.T) {
h := NewPluginAdminHandler(nil, nil)
body, contentType := buildZipUpload(t, validPluginZip(t))
req := httptest.NewRequest("POST", "/install", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status: got %d, want 503", rec.Code)
}
}
func TestPluginsHandlerInstallRejectsNonZipContentType(t *testing.T) {
reg := newTestPluginRegistry(t)
h := NewPluginAdminHandler(reg, nil)
// Build a multipart body whose file part is labelled as text/plain.
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
partHeader := make(map[string][]string)
partHeader["Content-Disposition"] = []string{`form-data; name="plugin"; filename="evil.txt"`}
partHeader["Content-Type"] = []string{"text/plain"}
part, err := mw.CreatePart(partHeader)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(validPluginZip(t)); err != nil {
t.Fatal(err)
}
_ = mw.Close()
req := httptest.NewRequest("POST", "/install", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("status: got %d, want 415; body=%s", rec.Code, rec.Body.String())
}
}
func TestPluginsHandlerInstallRejectsNonZipMagic(t *testing.T) {
reg := newTestPluginRegistry(t)
h := NewPluginAdminHandler(reg, nil)
body, contentType := buildZipUpload(t, []byte("this is definitely not a zip"))
req := httptest.NewRequest("POST", "/install", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: got %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestPluginsHandlerInstallHappyPath(t *testing.T) {
reg := newTestPluginRegistry(t)
mem := store.NewMemStore()
// Wire the store into the handler so /list can show the new row. The
// registry already writes via its own PluginStore.
h := NewPluginAdminHandler(reg, mem)
body, contentType := buildZipUpload(t, validPluginZip(t))
req := httptest.NewRequest("POST", "/install", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status: got %d, want 201; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "hello") {
t.Fatalf("expected plugin name in response, got %q", rec.Body.String())
}
}
func TestPluginsHandlerEnableDisableUninstallReturn503WhenRegistryNil(t *testing.T) {
h := NewPluginAdminHandler(nil, nil)
for _, tc := range []struct{ method, path string }{
{"POST", "/1/enable"},
{"POST", "/1/disable"},
{"DELETE", "/1"},
} {
req := httptest.NewRequest(tc.method, tc.path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("%s %s status: got %d, want 503", tc.method, tc.path, rec.Code)
}
}
}
func TestPluginsHandlerLifecycleInvalidID(t *testing.T) {
reg := newTestPluginRegistry(t)
h := NewPluginAdminHandler(reg, nil)
req := httptest.NewRequest("POST", "/not-an-int/enable", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: got %d, want 400", rec.Code)
}
}
func TestIsZipContentType(t *testing.T) {
cases := map[string]bool{
"application/zip": true,
"application/zip; charset=binary": true,
"APPLICATION/ZIP": true,
"application/x-zip-compressed": true,
"application/octet-stream": true,
"text/plain": false,
"image/png": false,
"": false,
"application/json; charset=utf-8": false,
}
for ct, want := range cases {
if got := isZipContentType(ct); got != want {
t.Errorf("isZipContentType(%q) = %v, want %v", ct, got, want)
}
}
}
func TestHasZipMagic(t *testing.T) {
cases := map[string]bool{
"PK\x03\x04rest": true,
"PK\x05\x06": true,
"PK\x07\x08rest": false, // spanned-archive signature; not accepted here
"not a zip": false,
"": false,
"PK": false,
}
for body, want := range cases {
if got := hasZipMagic([]byte(body)); got != want {
t.Errorf("hasZipMagic(%q) = %v, want %v", body, got, want)
}
}
}
// ── helpers ────────────────────────────────────────────────────────────────
func newTestPluginRegistry(t *testing.T) *plugin.Registry {
t.Helper()
dir := t.TempDir()
mem := store.NewMemStore()
reg, err := plugin.NewRegistry(plugin.Config{
Directory: filepath.Join(dir, "plugins"),
Store: mem,
})
if err != nil {
t.Fatalf("plugin.NewRegistry: %v", err)
}
t.Cleanup(func() { _ = reg.Close(context.Background()) })
return reg
}
// validPluginZip returns a minimal but structurally valid plugin package:
// a plugin.json manifest at the root plus a near-empty hello.wasm that is
// large enough to pass the entrypoint stat but small enough to fly well
// under the zip-bomb cap.
func validPluginZip(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
mj, err := zw.Create("plugin.json")
if err != nil {
t.Fatal(err)
}
if _, err := mj.Write([]byte(`{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`)); err != nil {
t.Fatal(err)
}
w, err := zw.Create("hello.wasm")
if err != nil {
t.Fatal(err)
}
// Minimal "placeholder" wasm magic bytes. Default build does not attempt
// to compile the module, so any bytes with the wasm magic suffice for
// InstallFromZip's on-disk validation.
if _, err := w.Write([]byte("\x00asm\x01\x00\x00\x00")); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// buildZipUpload wraps bodyBytes in a multipart form with a single "plugin"
// file part labelled as application/zip.
func buildZipUpload(t *testing.T, bodyBytes []byte) (io.Reader, string) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
partHeader := make(map[string][]string)
partHeader["Content-Disposition"] = []string{`form-data; name="plugin"; filename="hello.zip"`}
partHeader["Content-Type"] = []string{"application/zip"}
part, err := mw.CreatePart(partHeader)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(bodyBytes); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
return &buf, mw.FormDataContentType()
}
+48 -9
View File
@@ -48,10 +48,12 @@ type Registry struct {
// Subscribe; the WS hub calls sink.Dispatch on each broadcast.
sink *EventSink
// runtimePlatform is set by the wazero-tagged build's NewRegistry to a
// concrete *wazero.Runtime. The default build leaves it nil and falls
// back to manifest-only behaviour.
// runtimePlatform is populated by platformInit in the wazero-tagged build
// with a concrete *wazero.Runtime. The default build leaves it nil and
// falls back to manifest-only behaviour. platformClose tears the runtime
// down; both fields are set by platformInit atomically.
runtimePlatform any
platformClose func(context.Context) error
}
// Instance is a single loaded plugin.
@@ -81,20 +83,32 @@ func NewRegistry(cfg Config) (*Registry, error) {
if cfg.Store == nil {
return nil, fmt.Errorf("plugin: NewRegistry requires a non-nil PluginStore")
}
return &Registry{
r := &Registry{
cfg: cfg,
plugins: make(map[int64]*Instance),
byName: make(map[string]*Instance),
commands: make(map[string]*Instance),
sink: NewEventSink(),
}, nil
}
// platformInit is supplied by sandbox_default.go (no-op) or
// sandbox_wazero.go (real Wazero runtime). Either way it owns the
// runtimePlatform + platformClose pair on the Registry.
platform, closeFn, err := platformInit(cfg)
if err != nil {
return nil, fmt.Errorf("plugin: platform init: %w", err)
}
r.runtimePlatform = platform
r.platformClose = closeFn
return r, nil
}
// Close shuts the registry down. In the wazero-tagged build it tears the
// runtime down and frees module memory.
func (r *Registry) Close(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
for _, inst := range r.plugins {
r.platformDeactivate(inst)
}
for id := range r.plugins {
delete(r.plugins, id)
}
@@ -105,6 +119,13 @@ func (r *Registry) Close(ctx context.Context) error {
delete(r.commands, c)
}
r.uiTabs = nil
closeFn := r.platformClose
r.platformClose = nil
r.runtimePlatform = nil
r.mu.Unlock()
if closeFn != nil {
return closeFn(ctx)
}
return nil
}
@@ -391,9 +412,21 @@ func (r *Registry) activateAll(ctx context.Context) error {
// activate compiles and starts a single plugin module. Default build returns
// ErrRuntimeUnavailable; the wazero-tagged build replaces this with the real
// implementation via the runtimePlatform field.
// implementation via activateWithRuntime.
//
// The runtimePlatform read is guarded by r.mu so a concurrent Close() that
// nil-s the field cannot be observed mid-activation. The captured platform
// value is then passed into activateWithRuntime as a parameter so the actual
// compile uses the snapshot rather than re-reading r.runtimePlatform — this
// closes the race window between the nil check and the wazero call.
func (r *Registry) activate(ctx context.Context, inst *Instance) error {
return r.activateWithRuntime(ctx, inst)
r.mu.RLock()
platform := r.runtimePlatform
r.mu.RUnlock()
if platform == nil {
return ErrRuntimeUnavailable
}
return r.activateWithRuntime(ctx, platform, inst)
}
// EnablePlugin marks a plugin enabled in the store, then attempts to load it.
@@ -422,7 +455,9 @@ func (r *Registry) EnablePlugin(ctx context.Context, id int64) error {
return nil
}
// DisablePlugin marks a plugin disabled and tears its module down.
// DisablePlugin marks a plugin disabled and tears its module down. The
// wazero-tagged build frees the compiled module via platformDeactivate so
// re-enabling recompiles from disk; the default build is a no-op.
func (r *Registry) DisablePlugin(ctx context.Context, id int64) error {
if err := r.cfg.Store.DisablePlugin(ctx, id); err != nil {
return err
@@ -437,6 +472,10 @@ func (r *Registry) DisablePlugin(ctx context.Context, id int64) error {
delete(r.commands, cmd)
}
}
// Free the wazero module so memory is returned to the runtime
// immediately rather than waiting for registry Close. Safe to call
// on an instance that was never activated.
r.platformDeactivate(inst)
}
return nil
}
+12 -2
View File
@@ -9,15 +9,25 @@ import (
"context"
)
// platformInit is a no-op in the default build — there is no Wazero runtime
// to stand up, and NewRegistry leaves runtimePlatform nil so the lifecycle
// methods fall through to ErrRuntimeUnavailable.
func platformInit(_ Config) (any, func(context.Context) error, error) {
return nil, nil, nil
}
// activateWithRuntime is a no-op in the default build. It is only called from
// Registry.activate when runtimePlatform is non-nil, which never happens here.
func (r *Registry) activateWithRuntime(ctx context.Context, inst *Instance) error {
func (r *Registry) activateWithRuntime(_ context.Context, _ any, _ *Instance) error {
return ErrRuntimeUnavailable
}
// platformDeactivate is called from Close on each plugin; a no-op here.
func (r *Registry) platformDeactivate(_ *Instance) {}
// invokeCommand returns an error result instructing the operator to enable
// the wazero build tag. Default build only.
func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, channelID int64, cmd string, args []string) (*CommandResult, bool) {
func (r *Registry) invokeCommand(_ context.Context, _ *Instance, _, _ int64, _ string, _ []string) (*CommandResult, bool) {
return &CommandResult{
Reply: "plugin runtime disabled — rebuild server with -tags wazero to execute plugin commands",
}, true
+104 -53
View File
@@ -1,8 +1,34 @@
//go:build wazero
// Real Wazero-backed plugin runtime. Compiled only with `-tags wazero`.
// Provides concrete implementations of activateWithRuntime and invokeCommand
// that compile, instantiate, and dispatch to WASM modules via wazero v1.
// Phase C Step 9 — Real Wazero-backed plugin runtime. Compiled only with
// `-tags wazero`; matches the postgres / otel build-tag pattern used
// elsewhere in the repo so the default sqlite-only build does not pull
// wazero into go.mod at runtime.
//
// Architecture
//
// The wazero-tagged build provides:
//
// platformInit — creates the shared wazero.Runtime (with the
// configured memory cap and WASI preview-1 imports)
// and returns a teardown closure consumed by
// Registry.Close.
// activateWithRuntime — compiles the plugin's .wasm entrypoint, instantiates
// it against the shared runtime, stores the resulting
// api.Module on the Instance, and auto-registers any
// commands the module exports via list_commands.
// platformDeactivate — closes the per-plugin module without tearing down
// the shared runtime; called by DisablePlugin so a
// disabled plugin frees memory immediately.
// invokeCommand — calls the plugin's `command_dispatch` export using
// the JSON-ABI:
// allocate(size u32) → ptr u32
// command_dispatch(ptr u32, len u32) → (ptr u32, len u32)
// deallocate(ptr u32, len u32)
// Both input and output payloads are JSON.
//
// Plugins that do not export command_dispatch / allocate are still loadable
// — DispatchCommand reports a user-facing diagnostic instead of crashing.
package plugin
import (
@@ -20,65 +46,72 @@ import (
// wazeroPageBytes is the size of a single WASM linear-memory page (64 KiB).
const wazeroPageBytes = 65536
// ensureRuntimeLocked initialises the shared wazero.Runtime on first call.
// The caller MUST hold r.mu.Lock().
func (r *Registry) ensureRuntimeLocked(ctx context.Context) (wazero.Runtime, error) {
if r.runtimePlatform != nil {
rt, ok := r.runtimePlatform.(wazero.Runtime)
if !ok {
return nil, fmt.Errorf("plugin: runtimePlatform is not a wazero.Runtime")
}
return rt, nil
}
// platformInit stands up the shared wazero runtime for this Registry. The
// runtime is the top-level handle that owns compiled modules, host modules,
// and per-instance linear memory; every plugin in this registry shares it.
//
// The configured MaxMemoryMB cap is translated to wazero's per-page memory
// limit (64 KiB pages) and applied at runtime construction. WASI preview-1
// is pre-instantiated so plugins compiled with the standard TinyGo / Rust
// wasm32-wasi targets can resolve their syscall imports.
func platformInit(cfg Config) (any, func(context.Context) error, error) {
ctx := context.Background()
// Convert the configured MB limit to WASM pages (64 KiB each).
memMB := r.cfg.MaxMemoryMB
memMB := cfg.MaxMemoryMB
if memMB <= 0 {
memMB = 64 // default 64 MiB
memMB = 64 // default 64 MiB per plugin runtime
}
memPages := uint32(memMB) * 1024 * 1024 / wazeroPageBytes
rtCfg := wazero.NewRuntimeConfig().WithMemoryLimitPages(memPages)
rt := wazero.NewRuntimeWithConfig(ctx, rtCfg)
// Instantiate the WASI host module so plugins compiled with the WASI
// target (TinyGo, wasm32-wasi, etc.) have their syscall shims available.
rt := wazero.NewRuntimeWithConfig(ctx,
wazero.NewRuntimeConfig().
WithMemoryLimitPages(memPages).
WithCloseOnContextDone(true),
)
if _, err := wasi_snapshot_preview1.Instantiate(ctx, rt); err != nil {
_ = rt.Close(ctx)
return nil, fmt.Errorf("plugin: wasi instantiation: %w", err)
return nil, nil, fmt.Errorf("wazero: wasi snapshot_preview1: %w", err)
}
r.runtimePlatform = rt
return rt, nil
closeFn := func(shutCtx context.Context) error {
return rt.Close(shutCtx)
}
return rt, closeFn, nil
}
// activateWithRuntime compiles inst.WASMPath into a wazero module, instantiates
// it, and wires up the command dispatch table for any commands the module exports.
func (r *Registry) activateWithRuntime(ctx context.Context, inst *Instance) error {
// activateWithRuntime compiles inst.WASMPath into a wazero module and
// instantiates it under the shared runtime. The resulting api.Module is
// stashed on inst.module so lifecycle teardown (Close, DisablePlugin) can
// free it without walking the registry again.
//
// The runtime is passed in by Registry.activate as a captured snapshot so
// this function never re-reads r.runtimePlatform — that field can be nil-ed
// concurrently by Close, but the snapshot remains valid (the wazero runtime
// itself returns an error gracefully if it has been closed underneath us).
func (r *Registry) activateWithRuntime(ctx context.Context, platform any, inst *Instance) error {
rt, ok := platform.(wazero.Runtime)
if !ok || rt == nil {
return fmt.Errorf("plugin %q: wazero runtime unavailable", inst.Manifest.Name)
}
wasmBytes, err := os.ReadFile(inst.WASMPath)
if err != nil {
return fmt.Errorf("plugin %q: read wasm: %w", inst.Manifest.Name, err)
}
// Ensure the shared runtime is ready (lazy init, holds lock only briefly).
r.mu.Lock()
rt, err := r.ensureRuntimeLocked(ctx)
r.mu.Unlock()
if err != nil {
return err
}
// CompileModule is CPU-intensive but requires no lock.
// CompileModule is CPU-bound; do it without holding the registry lock.
compiled, err := rt.CompileModule(ctx, wasmBytes)
if err != nil {
return fmt.Errorf("plugin %q: compile: %w", inst.Manifest.Name, err)
}
// Each plugin gets its own module name so multiple instances can coexist
// without colliding in the runtime's global module namespace. Output is
// swallowed so a misbehaving plugin can't flood server logs. _start is
// suppressed so exports are only invoked on demand.
modCfg := wazero.NewModuleConfig().
WithName(inst.Manifest.Name).
WithStdout(io.Discard).
WithStderr(io.Discard).
WithStartFunctions() // suppress _start; exports are called on demand
WithStartFunctions()
module, err := rt.InstantiateModule(ctx, compiled, modCfg)
if err != nil {
@@ -86,31 +119,46 @@ func (r *Registry) activateWithRuntime(ctx context.Context, inst *Instance) erro
return fmt.Errorf("plugin %q: instantiate: %w", inst.Manifest.Name, err)
}
// Register the module and bind any commands it exports.
// Register the module and auto-bind any commands the plugin exports
// via list_commands. The plugin must also have declared the `commands`
// capability in its manifest, otherwise no binding happens.
r.mu.Lock()
inst.module = module
for _, perm := range inst.Manifest.Permissions {
if Capability(perm) == CapCommands {
for _, cmd := range listExportedCommands(ctx, module) {
r.commands[cmd] = inst
}
break
if inst.Manifest.HasCapability(CapCommands) {
for _, cmd := range listExportedCommands(ctx, module) {
r.commands[cmd] = inst
}
}
r.mu.Unlock()
return nil
}
// invokeCommand calls the plugin's exported "command_dispatch" function using
// a simple linear-memory ABI:
// platformDeactivate closes the wazero module held by inst without touching
// the shared runtime. Safe to call on an instance that was never activated.
// Called from DisablePlugin and Close.
func (r *Registry) platformDeactivate(inst *Instance) {
if inst == nil || inst.module == nil {
return
}
if mod, ok := inst.module.(api.Module); ok {
_ = mod.Close(context.Background())
}
inst.module = nil
}
// invokeCommand calls the plugin's exported `command_dispatch` function
// using a small JSON ABI:
//
// allocate(size u32) → ptr u32
// command_dispatch(ptr u32, len u32) → (result_ptr u32, result_len u32)
// deallocate(ptr u32, len u32)
//
// Both the input and output are JSON payloads.
// Both the input and output payloads are JSON. A plugin that does not
// export command_dispatch returns (nil, false) so the WS dispatcher can
// fall back to the not-found response. A plugin that exports
// command_dispatch but lacks allocate returns a user-facing diagnostic.
func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, channelID int64, cmd string, args []string) (*CommandResult, bool) {
if inst.module == nil {
if inst == nil || inst.module == nil {
return nil, false
}
mod, ok := inst.module.(api.Module)
@@ -120,7 +168,9 @@ func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, ch
dispatchFn := mod.ExportedFunction("command_dispatch")
if dispatchFn == nil {
return nil, false
return &CommandResult{
Reply: fmt.Sprintf("plugin %q does not export command_dispatch (rebuild the plugin to handle /%s)", inst.Manifest.Name, cmd),
}, true
}
allocFn := mod.ExportedFunction("allocate")
deallocFn := mod.ExportedFunction("deallocate")
@@ -187,9 +237,10 @@ func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, ch
return &CommandResult{Reply: dr.Reply}, true
}
// listExportedCommands calls the plugin's optional "list_commands" export which
// returns (ptr u32, len u32) pointing to a JSON array of command name strings.
// If the export is absent or returns invalid JSON, an empty slice is returned.
// listExportedCommands calls the plugin's optional `list_commands` export
// which returns (ptr u32, len u32) pointing to a JSON array of command name
// strings. If the export is absent or returns invalid JSON, an empty slice
// is returned and no command bindings are created.
func listExportedCommands(ctx context.Context, mod api.Module) []string {
fn := mod.ExportedFunction("list_commands")
if fn == nil {
+228
View File
@@ -0,0 +1,228 @@
//go:build wazero
// Phase C Step 9 — Wazero runtime integration tests. Only compiled with
// `-tags wazero`, alongside sandbox_wazero.go. These tests exercise the
// behaviours the default build cannot:
//
// - NewRegistry stands up a real wazero.Runtime
// - activateWithRuntime compiles a real .wasm module and tracks the
// resulting instance on the Instance struct
// - EnablePlugin takes a manifest from a PluginStore row through
// activation end-to-end
// - invokeCommand gracefully returns a user-facing error when the plugin
// does not export command_dispatch
// - Close tears the runtime down without panicking
//
// Test fixture: the 41-byte `add.wasm` module from the wazero examples, a
// trivial module that exports `add(i32,i32) -> i32`. It does NOT export
// `command_dispatch`, which is intentional — the command path must handle
// that case.
package plugin
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/owncord/server/store"
)
// addWASM is the bytes of a minimal (module (func (export "add") ... )).
// Verified against the wazero examples fixture; 41 bytes. Using a literal
// here avoids dragging a binary asset into the repo.
var addWASM = []byte{
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01,
0x7f, 0x03, 0x02, 0x01, 0x00, 0x07, 0x07, 0x01,
0x03, 0x61, 0x64, 0x64, 0x00, 0x00, 0x0a, 0x09,
0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a,
0x0b,
}
func writeTestPlugin(t *testing.T, root, name string, manifest string, wasmBytes []byte) {
t.Helper()
pluginDir := filepath.Join(root, name)
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "hello.wasm"), wasmBytes, 0o644); err != nil {
t.Fatal(err)
}
}
func newWazeroTestRegistry(t *testing.T, dir string) (*Registry, store.PluginStore) {
t.Helper()
mem := store.NewMemStore()
reg, err := NewRegistry(Config{
Directory: dir,
MaxMemoryMB: 16,
CPUBudgetMs: 100,
Store: mem,
})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
t.Cleanup(func() { _ = reg.Close(context.Background()) })
return reg, mem
}
func TestWazeroRegistryCreatesRuntime(t *testing.T) {
reg, _ := newWazeroTestRegistry(t, t.TempDir())
if reg.runtimePlatform == nil {
t.Fatal("expected wazero runtime to be wired in tagged build")
}
if reg.platformClose == nil {
t.Fatal("expected platformClose to be set")
}
}
func TestWazeroActivateCompilesModule(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
writeTestPlugin(t, dir, "hello", manifest, addWASM)
reg, mem := newWazeroTestRegistry(t, dir)
ctx := context.Background()
if err := reg.LoadAll(ctx); err != nil {
t.Fatalf("LoadAll: %v", err)
}
rows, err := mem.ListPlugins(ctx)
if err != nil || len(rows) != 1 {
t.Fatalf("ListPlugins: rows=%+v err=%v", rows, err)
}
// The plugin starts disabled; enable it and confirm the module compiles.
if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil {
t.Fatalf("EnablePlugin: %v", err)
}
reg.mu.RLock()
inst := reg.plugins[rows[0].ID]
reg.mu.RUnlock()
if inst == nil {
t.Fatal("instance not registered after enable")
}
if !inst.Enabled {
t.Fatal("instance should be enabled after EnablePlugin")
}
if inst.module == nil {
t.Fatal("expected inst.module to be populated after activation")
}
}
func TestWazeroDispatchCommandMissingExport(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
writeTestPlugin(t, dir, "hello", manifest, addWASM)
reg, mem := newWazeroTestRegistry(t, dir)
ctx := context.Background()
if err := reg.LoadAll(ctx); err != nil {
t.Fatal(err)
}
rows, _ := mem.ListPlugins(ctx)
if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil {
t.Fatalf("EnablePlugin: %v", err)
}
reg.mu.RLock()
inst := reg.plugins[rows[0].ID]
reg.mu.RUnlock()
if err := reg.RegisterCommand("hello", inst); err != nil {
t.Fatalf("RegisterCommand: %v", err)
}
result, ok := reg.DispatchCommand(ctx, 1, 2, "hello", nil)
if !ok {
t.Fatal("expected DispatchCommand to return a result")
}
if result == nil || result.Reply == "" {
t.Fatal("expected a non-empty reply when export is missing")
}
}
func TestWazeroCloseTearsDownRuntime(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
writeTestPlugin(t, dir, "hello", manifest, addWASM)
reg, mem := newWazeroTestRegistry(t, dir)
ctx := context.Background()
if err := reg.LoadAll(ctx); err != nil {
t.Fatal(err)
}
rows, _ := mem.ListPlugins(ctx)
if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil {
t.Fatalf("EnablePlugin: %v", err)
}
if err := reg.Close(ctx); err != nil {
t.Fatalf("Close: %v", err)
}
if reg.runtimePlatform != nil || reg.platformClose != nil {
t.Fatal("Close should clear platform fields")
}
// Calling Close twice must not panic.
if err := reg.Close(ctx); err != nil {
t.Fatalf("Close (second): %v", err)
}
}
func TestWazeroDisablePluginFreesModule(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
writeTestPlugin(t, dir, "hello", manifest, addWASM)
reg, mem := newWazeroTestRegistry(t, dir)
ctx := context.Background()
if err := reg.LoadAll(ctx); err != nil {
t.Fatal(err)
}
rows, _ := mem.ListPlugins(ctx)
if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil {
t.Fatalf("EnablePlugin: %v", err)
}
reg.mu.RLock()
inst := reg.plugins[rows[0].ID]
reg.mu.RUnlock()
if inst.module == nil {
t.Fatal("pre-condition: module should be populated after EnablePlugin")
}
if err := reg.DisablePlugin(ctx, rows[0].ID); err != nil {
t.Fatalf("DisablePlugin: %v", err)
}
if inst.module != nil {
t.Fatal("DisablePlugin must release the wazero module (inst.module != nil)")
}
if inst.Enabled {
t.Fatal("DisablePlugin must clear inst.Enabled")
}
// Re-enabling must rebuild a new module.
if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil {
t.Fatalf("re-EnablePlugin: %v", err)
}
if inst.module == nil {
t.Fatal("re-Enable should repopulate inst.module")
}
}
func TestWazeroInvalidWASMFailsActivation(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"brokey","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
writeTestPlugin(t, dir, "brokey", manifest, []byte("not a wasm"))
reg, mem := newWazeroTestRegistry(t, dir)
ctx := context.Background()
if err := reg.LoadAll(ctx); err != nil {
t.Fatal(err)
}
rows, _ := mem.ListPlugins(ctx)
if err := reg.EnablePlugin(ctx, rows[0].ID); err == nil {
t.Fatal("expected EnablePlugin to fail on invalid WASM")
}
}
+35 -22
View File
@@ -34,33 +34,46 @@ type AppMetrics struct {
}
var (
appMetricsOnce sync.Once
appMetricsMu sync.Mutex
appMetricsInst *AppMetrics
)
// NewAppMetrics returns a process-wide AppMetrics, lazily constructed against
// the current global provider. Calling it multiple times returns the same
// instance — the metrics are tied to the global provider, not to a specific
// caller.
// instance until resetAppMetricsForInit() is called (which Init uses after
// swapping the global provider so instruments re-bind to the new meter).
func NewAppMetrics() *AppMetrics {
appMetricsOnce.Do(func() {
ws := GlobalMeter(scopeWS)
svc := GlobalMeter(scopeService)
db := GlobalMeter(scopeDB)
voice := GlobalMeter(scopeVoice)
appMetricsInst = &AppMetrics{
WSMessagesTotal: ws.Counter("ws_messages_total", "WebSocket messages broadcast"),
WSActiveConnections: ws.Gauge("ws_active_connections", "Currently connected WebSocket clients"),
WSBroadcastLatency: ws.Histogram("ws_broadcast_latency_seconds", "Wall-clock seconds from enqueue to fanout completion", "s"),
WSReconnectTierTotal: ws.Counter("ws_reconnect_tier_total", "Reconnection replay tier hits, attribute tier=buffer|db|full"),
WSEventsPersisted: ws.Counter("ws_events_persisted_total", "Events written to the cold-tier event log"),
WSEventsDropped: ws.Counter("ws_events_dropped_total", "Events dropped because the persister queue was full"),
WSEventsPersistErrors: ws.Counter("ws_events_persist_errors_total", "PersistEvent calls that returned an error from the underlying store"),
DBQueryDurationSec: db.Histogram("db_query_duration_seconds", "Per-query wall time", "s"),
VoiceActiveSessions: voice.Gauge("voice_active_sessions", "Active LiveKit rooms"),
VoiceParticipants: voice.Gauge("voice_participants", "Connected LiveKit participants across all rooms"),
ServiceCallDurationSec: svc.Histogram("service_call_duration_seconds", "Service-layer method execution time", "s"),
}
})
appMetricsMu.Lock()
defer appMetricsMu.Unlock()
if appMetricsInst != nil {
return appMetricsInst
}
ws := GlobalMeter(scopeWS)
svc := GlobalMeter(scopeService)
db := GlobalMeter(scopeDB)
voice := GlobalMeter(scopeVoice)
appMetricsInst = &AppMetrics{
WSMessagesTotal: ws.Counter("ws_messages_total", "WebSocket messages broadcast"),
WSActiveConnections: ws.Gauge("ws_active_connections", "Currently connected WebSocket clients"),
WSBroadcastLatency: ws.Histogram("ws_broadcast_latency_seconds", "Wall-clock seconds from enqueue to fanout completion", "s"),
WSReconnectTierTotal: ws.Counter("ws_reconnect_tier_total", "Reconnection replay tier hits, attribute tier=buffer|db|full"),
WSEventsPersisted: ws.Counter("ws_events_persisted_total", "Events written to the cold-tier event log"),
WSEventsDropped: ws.Counter("ws_events_dropped_total", "Events dropped because the persister queue was full"),
WSEventsPersistErrors: ws.Counter("ws_events_persist_errors_total", "PersistEvent calls that returned an error from the underlying store"),
DBQueryDurationSec: db.Histogram("db_query_duration_seconds", "Per-query wall time", "s"),
VoiceActiveSessions: voice.Gauge("voice_active_sessions", "Active LiveKit rooms"),
VoiceParticipants: voice.Gauge("voice_participants", "Connected LiveKit participants across all rooms"),
ServiceCallDurationSec: svc.Histogram("service_call_duration_seconds", "Service-layer method execution time", "s"),
}
return appMetricsInst
}
// resetAppMetricsForInit drops the cached AppMetrics bundle so the next
// NewAppMetrics() call re-binds instruments against whatever provider is now
// global. The real OTel Init uses this to migrate from the no-op provider
// installed by the package-level init() to the SDK-backed one.
func resetAppMetricsForInit() {
appMetricsMu.Lock()
defer appMetricsMu.Unlock()
appMetricsInst = nil
}
+207 -150
View File
@@ -1,239 +1,296 @@
//go:build otel
// Real OpenTelemetry-backed implementation. Compiled only with `-tags otel`.
// Replaces the no-op providers in telemetry_default.go for the duration of
// the process.
// Phase B Step 8 — Real OpenTelemetry-backed implementation. Compiled only
// with `-tags otel`, matching the postgres / wazero build-tag pattern used
// elsewhere in the repo. The default build ships telemetry_default.go with a
// no-op provider so sqlite-only binaries do not pull the OTel SDK in.
//
// Exporter modes (config.TelemetryConfig.Exporter):
//
// "none" — no-op provider; same as the default build.
// "prometheus" — pull-based metrics via a /metrics handler, spans are still
// processed by a tracer provider with no exporter.
// "otlp" — push-based traces over OTLP/gRPC to cfg.OTLPEndpoint AND
// pull-based metrics via the Prometheus exporter (operators
// typically want both).
//
// The concrete Provider adapts our tiny telemetry.* API onto the upstream
// OTel SDK so the rest of the codebase never depends on the SDK directly.
package telemetry
import (
"context"
"errors"
"fmt"
"math"
"net/http"
"sync"
"github.com/prometheus/client_golang/prometheus"
"github.com/owncord/server/config"
promclient "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
otlpgrpc "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
promexp "go.opentelemetry.io/otel/exporters/prometheus"
otelmetric "go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
otelprom "go.opentelemetry.io/otel/exporters/prometheus"
"go.opentelemetry.io/otel/metric"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
sdkresource "go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
oteltrace "go.opentelemetry.io/otel/trace"
"github.com/owncord/server/config"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
)
// otelProvider is the real OTel-backed Provider, active only in the otel build.
// otelProvider adapts the OTel SDK to the telemetry.Provider interface.
type otelProvider struct {
tp oteltrace.TracerProvider
mp otelmetric.MeterProvider
cfg config.TelemetryConfig
promHandler http.Handler
mw func(http.Handler) http.Handler
shutdowns []func(context.Context) error
tp *sdktrace.TracerProvider
mp *sdkmetric.MeterProvider
serviceName string
}
// Init wires the OTel SDK according to cfg.Exporter and installs the result
// as the global telemetry provider. The returned ShutdownFunc must be called
// on server shutdown to flush pending telemetry.
// Init wires the OTel SDK according to cfg and installs it as the global
// Provider. The returned ShutdownFunc flushes the batchers and releases
// exporter resources; it is safe to call more than once.
func Init(ctx context.Context, cfg config.TelemetryConfig) (ShutdownFunc, error) {
if !cfg.Enabled || cfg.Exporter == "" || cfg.Exporter == "none" {
SetGlobal(noopProvider{})
return func(context.Context) error { return nil }, nil
}
res, err := sdkresource.New(ctx,
sdkresource.WithAttributes(attribute.String("service.name", cfg.ServiceName)),
sdkresource.WithFromEnv(),
svcName := cfg.ServiceName
if svcName == "" {
svcName = "owncord-server"
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(svcName),
),
)
if err != nil {
// Non-fatal: fall back to the SDK default resource.
res = sdkresource.Default()
return nil, fmt.Errorf("telemetry: resource: %w", err)
}
p := &otelProvider{}
provider := &otelProvider{cfg: cfg, serviceName: svcName}
switch cfg.Exporter {
case "prometheus":
promReg := prometheus.NewRegistry()
exporter, expErr := promexp.New(promexp.WithRegisterer(promReg))
if expErr != nil {
return nil, fmt.Errorf("telemetry: prometheus exporter: %w", expErr)
// ── Tracing ────────────────────────────────────────────────────────────
tpOpts := []sdktrace.TracerProviderOption{sdktrace.WithResource(res)}
if cfg.Exporter == "otlp" {
endpoint := cfg.OTLPEndpoint
if endpoint == "" {
endpoint = "localhost:4317"
}
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithResource(res),
sdkmetric.WithReader(exporter),
)
otel.SetMeterProvider(mp)
p.mp = mp
p.promHandler = promhttp.HandlerFor(promReg, promhttp.HandlerOpts{EnableOpenMetrics: true})
p.shutdowns = append(p.shutdowns, mp.Shutdown)
case "otlp":
if cfg.OTLPEndpoint == "" {
return nil, fmt.Errorf("telemetry: exporter=otlp requires otlp_endpoint to be set")
}
otlpOpts := []otlpgrpc.Option{
otlpgrpc.WithEndpoint(cfg.OTLPEndpoint),
otlpOpts := []otlptracegrpc.Option{
otlptracegrpc.WithEndpoint(endpoint),
}
// OTLPInsecure honours the explicit operator opt-in for plaintext
// gRPC; production deployments should leave it false and provide
// a TLS endpoint.
if cfg.OTLPInsecure {
otlpOpts = append(otlpOpts, otlpgrpc.WithInsecure())
otlpOpts = append(otlpOpts, otlptracegrpc.WithInsecure())
}
exp, expErr := otlpgrpc.New(ctx, otlpOpts...)
if expErr != nil {
return nil, fmt.Errorf("telemetry: otlp exporter: %w", expErr)
traceExp, err := otlptracegrpc.New(ctx, otlpOpts...)
if err != nil {
return nil, fmt.Errorf("telemetry: otlp trace exporter: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
p.tp = tp
p.shutdowns = append(p.shutdowns, tp.Shutdown)
default:
return nil, fmt.Errorf("telemetry: unknown exporter %q (valid: none, prometheus, otlp)", cfg.Exporter)
tpOpts = append(tpOpts, sdktrace.WithBatcher(traceExp))
}
provider.tp = sdktrace.NewTracerProvider(tpOpts...)
otel.SetTracerProvider(provider.tp)
// HTTP tracing middleware using otelhttp (works with any http.Handler router).
p.mw = func(next http.Handler) http.Handler {
return otelhttp.NewHandler(next, cfg.ServiceName)
// ── Metrics ────────────────────────────────────────────────────────────
// Both prometheus and otlp modes surface metrics via a pull-based
// Prometheus endpoint. OTLP tracing does not preclude Prometheus metrics
// — operators usually want both — so we wire the exporter unconditionally
// for the two real modes.
reg := promclient.NewRegistry()
promExp, err := otelprom.New(otelprom.WithRegisterer(reg))
if err != nil {
// Shut the trace provider down so the OTLP exporter's gRPC
// connection (if any) is torn down. Init returning an error must
// not leak resources.
_ = provider.tp.Shutdown(ctx)
return nil, fmt.Errorf("telemetry: prometheus exporter: %w", err)
}
provider.mp = sdkmetric.NewMeterProvider(
sdkmetric.WithResource(res),
sdkmetric.WithReader(promExp),
)
otel.SetMeterProvider(provider.mp)
provider.promHandler = promhttp.HandlerFor(reg, promhttp.HandlerOpts{})
SetGlobal(p)
return p.shutdown, nil
// Drop the cached AppMetrics bundle BEFORE publishing the new global
// provider. A concurrent NewAppMetrics() caller that observes the old
// no-op provider and the stale cache is harmless (it just re-populates
// once); the failure mode we avoid is a caller seeing the new provider
// but still reading the old cached (no-op) instruments.
resetAppMetricsForInit()
SetGlobal(provider)
var once sync.Once
return func(shutCtx context.Context) error {
var rerr error
once.Do(func() {
var errs []error
if provider.tp != nil {
if err := provider.tp.Shutdown(shutCtx); err != nil {
errs = append(errs, err)
}
}
if provider.mp != nil {
if err := provider.mp.Shutdown(shutCtx); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
rerr = fmt.Errorf("telemetry shutdown: %w", errors.Join(errs...))
}
SetGlobal(noopProvider{})
})
return rerr
}, nil
}
func (p *otelProvider) shutdown(ctx context.Context) error {
var first error
for _, fn := range p.shutdowns {
if err := fn(ctx); err != nil && first == nil {
first = err
}
}
return first
}
// ── Provider interface ──────────────────────────────────────────────────────
// Tracer returns an otel-backed tracer for the given instrumentation scope.
func (p *otelProvider) Tracer(name string) Tracer {
tp := p.tp
if tp == nil {
tp = otel.GetTracerProvider()
}
return otelTracerBridge{t: tp.Tracer(name)}
return &otelTracer{inner: p.tp.Tracer(name)}
}
// Meter returns an otel-backed meter for the given instrumentation scope.
func (p *otelProvider) Meter(name string) Meter {
mp := p.mp
if mp == nil {
mp = otel.GetMeterProvider()
}
return otelMeterBridge{m: mp.Meter(name)}
return &otelMeter{inner: p.mp.Meter(name)}
}
// HTTPMiddleware wraps next with otelhttp so every REST request becomes a
// span named after its route pattern.
func (p *otelProvider) HTTPMiddleware(next http.Handler) http.Handler {
if p.mw == nil {
return next
}
return p.mw(next)
return otelhttp.NewHandler(next, "http.server",
otelhttp.WithServerName(p.serviceName),
)
}
// PrometheusHandler returns the /metrics handler backed by the active
// exporter registry.
func (p *otelProvider) PrometheusHandler() http.Handler { return p.promHandler }
// ── Bridge: Tracer / Span ───────────────────────────────────────────────────
// ── Tracer / Span adapters ─────────────────────────────────────────────────
type otelTracerBridge struct{ t oteltrace.Tracer }
type otelTracer struct{ inner trace.Tracer }
func (b otelTracerBridge) Start(ctx context.Context, name string, attrs ...Attr) (context.Context, Span) {
var opts []oteltrace.SpanStartOption
if len(attrs) > 0 {
opts = append(opts, oteltrace.WithAttributes(toKVs(attrs)...))
}
ctx, span := b.t.Start(ctx, name, opts...)
return ctx, otelSpanBridge{s: span}
func (t *otelTracer) Start(ctx context.Context, name string, attrs ...Attr) (context.Context, Span) {
ctx, span := t.inner.Start(ctx, name, trace.WithAttributes(convertAttrs(attrs)...))
return ctx, &otelSpan{inner: span}
}
type otelSpanBridge struct{ s oteltrace.Span }
type otelSpan struct{ inner trace.Span }
func (b otelSpanBridge) End() { b.s.End() }
func (b otelSpanBridge) SetAttributes(attrs ...Attr) { b.s.SetAttributes(toKVs(attrs)...) }
func (b otelSpanBridge) RecordError(err error) { b.s.RecordError(err) }
func (s *otelSpan) End() { s.inner.End() }
func (s *otelSpan) SetAttributes(attrs ...Attr) { s.inner.SetAttributes(convertAttrs(attrs)...) }
func (s *otelSpan) RecordError(err error) {
if err == nil {
return
}
s.inner.RecordError(err)
}
// ── Bridge: Meter / instruments ────────────────────────────────────────────
// ── Meter / instrument adapters ────────────────────────────────────────────
type otelMeterBridge struct{ m otelmetric.Meter }
type otelMeter struct{ inner metric.Meter }
func (b otelMeterBridge) Counter(name, description string) Counter {
c, err := b.m.Int64Counter(name, otelmetric.WithDescription(description))
func (m *otelMeter) Counter(name, description string) Counter {
c, err := m.inner.Int64Counter(name, metric.WithDescription(description))
if err != nil {
return noopCounter{}
}
return otelCounterBridge{c: c}
return &otelCounter{inner: c}
}
func (b otelMeterBridge) Histogram(name, description, unit string) Histogram {
h, err := b.m.Float64Histogram(name,
otelmetric.WithDescription(description),
otelmetric.WithUnit(unit),
)
func (m *otelMeter) Histogram(name, description, unit string) Histogram {
opts := []metric.Float64HistogramOption{metric.WithDescription(description)}
if unit != "" {
opts = append(opts, metric.WithUnit(unit))
}
h, err := m.inner.Float64Histogram(name, opts...)
if err != nil {
return noopHistogram{}
}
return otelHistogramBridge{h: h}
return &otelHistogram{inner: h}
}
func (b otelMeterBridge) Gauge(name, description string) Gauge {
g, err := b.m.Float64Gauge(name, otelmetric.WithDescription(description))
func (m *otelMeter) Gauge(name, description string) Gauge {
g, err := m.inner.Float64Gauge(name, metric.WithDescription(description))
if err != nil {
return noopGauge{}
}
return otelGaugeBridge{g: g}
return &otelGauge{inner: g}
}
type otelCounterBridge struct{ c otelmetric.Int64Counter }
type otelCounter struct{ inner metric.Int64Counter }
func (b otelCounterBridge) Add(ctx context.Context, delta int64, attrs ...Attr) {
b.c.Add(ctx, delta, otelmetric.WithAttributes(toKVs(attrs)...))
func (c *otelCounter) Add(ctx context.Context, delta int64, attrs ...Attr) {
c.inner.Add(ctx, delta, metric.WithAttributes(convertAttrs(attrs)...))
}
type otelHistogramBridge struct{ h otelmetric.Float64Histogram }
type otelHistogram struct{ inner metric.Float64Histogram }
func (b otelHistogramBridge) Record(ctx context.Context, value float64, attrs ...Attr) {
b.h.Record(ctx, value, otelmetric.WithAttributes(toKVs(attrs)...))
func (h *otelHistogram) Record(ctx context.Context, value float64, attrs ...Attr) {
h.inner.Record(ctx, value, metric.WithAttributes(convertAttrs(attrs)...))
}
type otelGaugeBridge struct{ g otelmetric.Float64Gauge }
type otelGauge struct{ inner metric.Float64Gauge }
func (b otelGaugeBridge) Set(ctx context.Context, value float64, attrs ...Attr) {
b.g.Record(ctx, value, otelmetric.WithAttributes(toKVs(attrs)...))
func (g *otelGauge) Set(ctx context.Context, value float64, attrs ...Attr) {
g.inner.Record(ctx, value, metric.WithAttributes(convertAttrs(attrs)...))
}
// ── Attribute helpers ───────────────────────────────────────────────────────
func toKV(a Attr) attribute.KeyValue {
switch v := a.Value.(type) {
case string:
return attribute.String(a.Key, v)
case int64:
return attribute.Int64(a.Key, v)
case int:
return attribute.Int(a.Key, v)
case float64:
return attribute.Float64(a.Key, v)
case bool:
return attribute.Bool(a.Key, v)
default:
return attribute.String(a.Key, fmt.Sprintf("%v", v))
// convertAttrs maps telemetry.Attr values to attribute.KeyValue. Unknown
// types are rendered via fmt.Sprint so callers never panic on exotic values.
// The uint64 / uint32 / uint cases matter: sequence numbers and ID fields
// in OwnCord are unsigned, and routing them through the default fmt.Sprint
// path would encode them as string attributes and break metric aggregation.
func convertAttrs(in []Attr) []attribute.KeyValue {
if len(in) == 0 {
return nil
}
}
func toKVs(attrs []Attr) []attribute.KeyValue {
kvs := make([]attribute.KeyValue, 0, len(attrs))
for _, a := range attrs {
kvs = append(kvs, toKV(a))
out := make([]attribute.KeyValue, 0, len(in))
for _, a := range in {
switch v := a.Value.(type) {
case string:
out = append(out, attribute.String(a.Key, v))
case int:
out = append(out, attribute.Int(a.Key, v))
case int32:
out = append(out, attribute.Int64(a.Key, int64(v)))
case int64:
out = append(out, attribute.Int64(a.Key, v))
case uint:
out = append(out, attribute.Int64(a.Key, int64(v)))
case uint32:
out = append(out, attribute.Int64(a.Key, int64(v)))
case uint64:
// Most uint64 values in OwnCord (ids, seqs) fit comfortably
// within int64 range. A wrapped negative would corrupt
// metric aggregation, so we fall back to a string for the
// pathological case rather than silently misreporting.
if v <= math.MaxInt64 {
out = append(out, attribute.Int64(a.Key, int64(v)))
} else {
out = append(out, attribute.String(a.Key, fmt.Sprint(v)))
}
case float32:
out = append(out, attribute.Float64(a.Key, float64(v)))
case float64:
out = append(out, attribute.Float64(a.Key, v))
case bool:
out = append(out, attribute.Bool(a.Key, v))
default:
out = append(out, attribute.String(a.Key, fmt.Sprint(v)))
}
}
return kvs
return out
}
+214
View File
@@ -0,0 +1,214 @@
//go:build otel
// Phase B Step 8 — tests for the real OpenTelemetry provider. Only compiled
// under `-tags otel`, alongside telemetry_otel.go. These tests exercise the
// behavioural contract the no-op build cannot: that Init actually wires a
// Prometheus exporter, that spans created via the adapter reach the SDK, and
// that Shutdown flushes cleanly.
package telemetry
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/owncord/server/config"
)
func TestOtelInitPrometheusExporter(t *testing.T) {
resetGlobalForTest(t)
shutdown, err := Init(context.Background(), config.TelemetryConfig{
Enabled: true,
Exporter: "prometheus",
ServiceName: "owncord-test",
})
if err != nil {
t.Fatalf("Init: %v", err)
}
t.Cleanup(func() { _ = shutdown(context.Background()) })
p := Global()
if p == nil {
t.Fatal("Global is nil after Init")
}
handler := p.PrometheusHandler()
if handler == nil {
t.Fatal("PrometheusHandler is nil after prometheus Init")
}
// Record one metric and scrape the exporter; the metric must appear in
// the /metrics response body.
ctx := context.Background()
meter := p.Meter("telemetry_test")
counter := meter.Counter("otel_test_counter_total", "test counter")
counter.Add(ctx, 3, String("fixture", "init"))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest("GET", "/metrics", nil))
body := rec.Body.String()
if rec.Code != http.StatusOK {
t.Fatalf("prometheus handler status: got %d, body=%s", rec.Code, body)
}
if !strings.Contains(body, "otel_test_counter_total") {
t.Fatalf("expected otel_test_counter_total in exporter body:\n%s", body)
}
}
func TestOtelInitNoneReturnsNoopProvider(t *testing.T) {
resetGlobalForTest(t)
shutdown, err := Init(context.Background(), config.TelemetryConfig{
Enabled: true,
Exporter: "none",
})
if err != nil {
t.Fatalf("Init: %v", err)
}
t.Cleanup(func() { _ = shutdown(context.Background()) })
if _, ok := Global().(noopProvider); !ok {
t.Fatalf("expected noopProvider, got %T", Global())
}
}
func TestOtelInitDisabledReturnsNoopProvider(t *testing.T) {
resetGlobalForTest(t)
shutdown, err := Init(context.Background(), config.TelemetryConfig{Enabled: false})
if err != nil {
t.Fatalf("Init: %v", err)
}
t.Cleanup(func() { _ = shutdown(context.Background()) })
if _, ok := Global().(noopProvider); !ok {
t.Fatalf("expected noopProvider, got %T", Global())
}
}
func TestOtelTracerRecordsSpan(t *testing.T) {
resetGlobalForTest(t)
shutdown, err := Init(context.Background(), config.TelemetryConfig{
Enabled: true,
Exporter: "prometheus",
ServiceName: "owncord-test",
})
if err != nil {
t.Fatalf("Init: %v", err)
}
t.Cleanup(func() { _ = shutdown(context.Background()) })
tracer := Global().Tracer("telemetry_test")
ctx, span := tracer.Start(context.Background(), "unit_test",
String("attr_string", "v"),
Int64("attr_int", 42),
Float64("attr_float", 1.5),
)
span.SetAttributes(String("late", "ok"))
span.RecordError(errors.New("boom"))
span.End()
_ = ctx
}
func TestOtelHistogramRecordsSeconds(t *testing.T) {
resetGlobalForTest(t)
shutdown, err := Init(context.Background(), config.TelemetryConfig{
Enabled: true,
Exporter: "prometheus",
ServiceName: "owncord-test",
})
if err != nil {
t.Fatalf("Init: %v", err)
}
t.Cleanup(func() { _ = shutdown(context.Background()) })
h := Global().Meter("telemetry_test").Histogram("otel_test_latency_seconds", "test", "s")
TimeSince(context.Background(), h, time.Now().Add(-250*time.Millisecond))
}
func TestOtelShutdownIdempotent(t *testing.T) {
resetGlobalForTest(t)
shutdown, err := Init(context.Background(), config.TelemetryConfig{
Enabled: true,
Exporter: "prometheus",
ServiceName: "owncord-test",
})
if err != nil {
t.Fatalf("Init: %v", err)
}
if err := shutdown(context.Background()); err != nil {
t.Fatalf("first shutdown: %v", err)
}
// Second call must not panic or error.
if err := shutdown(context.Background()); err != nil {
t.Fatalf("second shutdown: %v", err)
}
}
// resetGlobalForTest restores a fresh no-op provider between tests that each
// call Init. We cannot cleanly tear down and re-register the otel globals in
// all cases, so tests that run back-to-back must start from the no-op state.
func resetGlobalForTest(t *testing.T) {
t.Helper()
SetGlobal(noopProvider{})
resetAppMetricsForInit()
}
func TestOtelConvertAttrsUint64OverflowFallsBackToString(t *testing.T) {
const tooBig = uint64(1<<63) + 7 // > math.MaxInt64
attrs := convertAttrs([]Attr{{Key: "huge", Value: tooBig}})
if len(attrs) != 1 {
t.Fatalf("expected 1 attr, got %d", len(attrs))
}
if got := attrs[0].Value.Type().String(); got != "STRING" {
t.Fatalf("overflowing uint64 should become STRING attr to avoid wrap, got %s", got)
}
}
func TestOtelConvertAttrsHandlesUnsignedInts(t *testing.T) {
attrs := convertAttrs([]Attr{
{Key: "u64", Value: uint64(1 << 40)},
{Key: "u32", Value: uint32(7)},
{Key: "u", Value: uint(42)},
{Key: "i", Value: int(-1)},
{Key: "i64", Value: int64(-1 << 40)},
{Key: "f32", Value: float32(2.5)},
{Key: "b", Value: true},
{Key: "s", Value: "hello"},
})
if len(attrs) != 8 {
t.Fatalf("expected 8 attrs, got %d", len(attrs))
}
// uint64 must become an Int64 attribute, not a string.
if got := attrs[0].Value.Type().String(); got != "INT64" {
t.Fatalf("u64 attr type = %s, want INT64", got)
}
if got := attrs[0].Value.AsInt64(); got != int64(1<<40) {
t.Fatalf("u64 attr value = %d, want %d", got, int64(1<<40))
}
}
func TestOtelAppMetricsRebindsAfterInit(t *testing.T) {
resetGlobalForTest(t)
// Build once against the noop global; verify the bundle is the no-op
// counter type, then swap providers and verify NewAppMetrics returns a
// re-bound bundle.
before := NewAppMetrics()
if _, ok := before.WSMessagesTotal.(noopCounter); !ok {
t.Fatalf("expected noopCounter before Init, got %T", before.WSMessagesTotal)
}
shutdown, err := Init(context.Background(), config.TelemetryConfig{
Enabled: true,
Exporter: "prometheus",
ServiceName: "owncord-test",
})
if err != nil {
t.Fatalf("Init: %v", err)
}
t.Cleanup(func() { _ = shutdown(context.Background()) })
after := NewAppMetrics()
if _, ok := after.WSMessagesTotal.(noopCounter); ok {
t.Fatalf("WSMessagesTotal is still a noopCounter after OTel Init — AppMetrics cache was not reset")
}
}