mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
One word stood for several: the MCP server, one address that accepts requests, the single way in, and the boundary a credential is validated at. Doc comments here are lifted into OpenAPI descriptions and republished on docs.hanzo.ai, so the ambiguity was reaching customers. Each site now names its own sense — endpoint, entry point, the MCP server, the edge, address — or takes the word the surrounding code already uses. Comments and prose strings only. Identifiers keep their names, including the doc comments that must open with the identifier they document. go build ./... passes.
466 lines
18 KiB
Go
466 lines
18 KiB
Go
package o11y_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzoai/o11y"
|
|
"github.com/hanzoai/o11y/pkg/http/render"
|
|
"github.com/hanzoai/o11y/pkg/types/sentrytypes"
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// THE WIRE PROOF.
|
|
//
|
|
// The five telemetry reads are typed ops now, and a typed op that changes what a
|
|
// caller receives is a break rather than a migration. So these tests do not
|
|
// assert that the answer "looks right" — they take the bytes the RUNTIME wrote
|
|
// and the bytes the OP sent and demand they are the same bytes, for a payload
|
|
// with every field of every type populated. A field this port failed to name, or
|
|
// named with a different tag, or ordered differently, shows up here as a diff.
|
|
|
|
// mounted builds the app the way the embedding host does: the telemetry ops on
|
|
// the native router, and the delegation wildcard behind them.
|
|
func mounted(t *testing.T) *zip.App {
|
|
t.Helper()
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
if err := o11y.Mount(app); err != nil {
|
|
t.Fatalf("Mount: %v", err)
|
|
}
|
|
return app
|
|
}
|
|
|
|
// runtime installs a stand-in for the o11y runtime that answers every call with
|
|
// the success envelope the real one renders — through the SAME render.Success
|
|
// the handlers use — and reports what it wrote and what it was asked.
|
|
func runtime(t *testing.T, payload any) (wrote *[]byte, asked **http.Request) {
|
|
t.Helper()
|
|
var body []byte
|
|
var req *http.Request
|
|
o11y.SetRuntime(o11y.Whole(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
read, _ := io.ReadAll(r.Body)
|
|
r.Body = io.NopCloser(bytes.NewReader(read))
|
|
req = r.Clone(r.Context())
|
|
req.Body = io.NopCloser(bytes.NewReader(read))
|
|
|
|
rec := httptest.NewRecorder()
|
|
render.Success(rec, http.StatusOK, payload)
|
|
body = rec.Body.Bytes()
|
|
for k, v := range rec.Header() {
|
|
w.Header()[k] = v
|
|
}
|
|
w.WriteHeader(rec.Code)
|
|
_, _ = w.Write(body)
|
|
})))
|
|
t.Cleanup(func() { o11y.SetRuntime(nil) })
|
|
return &body, &req
|
|
}
|
|
|
|
// call runs one request through the app and returns the status and body a caller
|
|
// would receive.
|
|
func call(t *testing.T, app *zip.App, r *http.Request) (int, []byte) {
|
|
t.Helper()
|
|
resp, err := app.Test(r)
|
|
if err != nil {
|
|
t.Fatalf("Test: %v", err)
|
|
}
|
|
got, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
return resp.StatusCode, got
|
|
}
|
|
|
|
// member is the request the gateway mints for a signed-in org member — the
|
|
// identity assertion these ops propagate and never mint.
|
|
func member(method, target string, body io.Reader) *http.Request {
|
|
r := httptest.NewRequest(method, target, body)
|
|
r.Header.Set(zip.HeaderOrg, "maxpower")
|
|
r.Header.Set(zip.HeaderUser, "z")
|
|
r.Header.Set(zip.HeaderUserEmail, "[email protected]")
|
|
if body != nil {
|
|
r.Header.Set("Content-Type", "application/json")
|
|
}
|
|
return r
|
|
}
|
|
|
|
// full is one event with EVERY field populated, so a dropped or renamed field
|
|
// cannot hide behind a zero value.
|
|
func full() *sentrytypes.Event {
|
|
at := time.Date(2026, 7, 31, 12, 0, 0, 123456789, time.UTC)
|
|
return &sentrytypes.Event{
|
|
OrgID: "maxpower", ProjectID: "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
|
|
EventID: "e1", Timestamp: at, ReceivedAt: at.Add(time.Second),
|
|
Level: "error", Type: "TypeError", Value: "x is not a function",
|
|
Message: "x is not a function", Culprit: "web/handler.go",
|
|
Fingerprint: "fp1", Platform: "go", Environment: "prod", Release: "v1.2.3",
|
|
ServiceName: "api", Transaction: "GET /v1/thing", TraceID: "t1", SpanID: "s1",
|
|
ServerName: "node-7", UserID: "u1", UserEmail: "[email protected]", UserIP: "203.0.113.7",
|
|
Handled: true,
|
|
Tags: map[string]string{"region": "sfo", "tier": "gold"},
|
|
Frames: []sentrytypes.Frame{
|
|
{Function: "main.serve", File: "main.go", Line: 42, Column: 7, Own: true},
|
|
{Function: "runtime.goexit", File: "asm.s", Line: 1650},
|
|
},
|
|
}
|
|
}
|
|
|
|
// A page of events is what the runtime wrote, to the byte.
|
|
func TestLogsAnswerIsTheRuntimeAnswer(t *testing.T) {
|
|
app := mounted(t)
|
|
wrote, asked := runtime(t, map[string]any{"items": []*sentrytypes.Event{full()}})
|
|
|
|
status, got := call(t, app, member(http.MethodGet, "/v1/o11y/sentinel/logs?project=p1&query=is+not&period=24h&limit=50", nil))
|
|
if status != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s, want 200", status, got)
|
|
}
|
|
if !bytes.Equal(got, *wrote) {
|
|
t.Fatalf("the op changed the bytes.\n runtime: %s\n op: %s", *wrote, got)
|
|
}
|
|
if r := *asked; r.URL.Path != "/v1/o11y/sentinel/logs" ||
|
|
r.URL.Query().Get("project") != "p1" || r.URL.Query().Get("query") != "is not" ||
|
|
r.URL.Query().Get("period") != "24h" || r.URL.Query().Get("limit") != "50" {
|
|
t.Fatalf("runtime was asked %s?%s, want the caller's own inputs", r.URL.Path, r.URL.RawQuery)
|
|
}
|
|
}
|
|
|
|
func TestTracesAnswerIsTheRuntimeAnswer(t *testing.T) {
|
|
at := time.Date(2026, 7, 30, 9, 30, 0, 0, time.UTC)
|
|
app := mounted(t)
|
|
wrote, asked := runtime(t, map[string]any{"items": []*sentrytypes.TraceSummary{
|
|
{TraceID: "t1", Count: 12, FirstSeen: at, LastSeen: at.Add(time.Hour), Message: "boom"},
|
|
{TraceID: "t2", Count: 1, FirstSeen: at, LastSeen: at},
|
|
}})
|
|
|
|
status, got := call(t, app, member(http.MethodGet, "/v1/o11y/sentinel/traces?project=p1&period=7d&limit=25", nil))
|
|
if status != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s, want 200", status, got)
|
|
}
|
|
if !bytes.Equal(got, *wrote) {
|
|
t.Fatalf("the op changed the bytes.\n runtime: %s\n op: %s", *wrote, got)
|
|
}
|
|
if r := *asked; r.URL.Path != "/v1/o11y/sentinel/traces" || r.URL.Query().Get("limit") != "25" {
|
|
t.Fatalf("runtime was asked %s?%s", r.URL.Path, r.URL.RawQuery)
|
|
}
|
|
}
|
|
|
|
// One trace's events — the shape the runtime renders is a map, whose keys Go
|
|
// marshals in sorted order. The Out has to be declared in that order or the
|
|
// bytes move, which is exactly what this pins.
|
|
func TestTraceAnswerIsTheRuntimeAnswer(t *testing.T) {
|
|
app := mounted(t)
|
|
wrote, asked := runtime(t, map[string]any{"traceId": "t1/2", "events": []*sentrytypes.Event{full()}})
|
|
|
|
status, got := call(t, app, member(http.MethodGet, "/v1/o11y/sentinel/traces/t1%2F2?project=p1", nil))
|
|
if status != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s, want 200", status, got)
|
|
}
|
|
if !bytes.Equal(got, *wrote) {
|
|
t.Fatalf("the op changed the bytes.\n runtime: %s\n op: %s", *wrote, got)
|
|
}
|
|
// The trace id is a path segment, so it reaches the runtime escaped exactly
|
|
// as the caller wrote it.
|
|
if r := *asked; r.URL.EscapedPath() != "/v1/o11y/sentinel/traces/t1%2F2" {
|
|
t.Fatalf("runtime was asked %q, want the id kept whole", r.URL.EscapedPath())
|
|
}
|
|
}
|
|
|
|
func TestStatsAnswerIsTheRuntimeAnswer(t *testing.T) {
|
|
at := time.Date(2026, 7, 31, 0, 0, 0, 0, time.UTC)
|
|
app := mounted(t)
|
|
wrote, asked := runtime(t, map[string]any{"items": []sentrytypes.StatsPoint{
|
|
{Time: at, Value: 9},
|
|
{Time: at.Add(time.Hour), Value: 0},
|
|
}})
|
|
|
|
status, got := call(t, app, member(http.MethodGet, "/v1/o11y/sentinel/stats?project=p1&field=level&period=24h", nil))
|
|
if status != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s, want 200", status, got)
|
|
}
|
|
if !bytes.Equal(got, *wrote) {
|
|
t.Fatalf("the op changed the bytes.\n runtime: %s\n op: %s", *wrote, got)
|
|
}
|
|
if r := *asked; r.URL.Query().Get("field") != "level" || r.URL.Query().Get("period") != "24h" {
|
|
t.Fatalf("runtime was asked %s?%s", r.URL.Path, r.URL.RawQuery)
|
|
}
|
|
}
|
|
|
|
// Discover is the one that carries a body. The body the runtime receives has to
|
|
// be the request the caller sent, field for field.
|
|
func TestDiscoverAnswerIsTheRuntimeAnswer(t *testing.T) {
|
|
app := mounted(t)
|
|
wrote, asked := runtime(t, &sentrytypes.DiscoverResult{
|
|
Columns: []string{"level", "count"},
|
|
Rows: [][]any{{"error", 12}, {"warning", 3}},
|
|
})
|
|
|
|
sent := `{"project":"p1","filters":[{"field":"level","op":"eq","value":"error"}],` +
|
|
`"aggregations":["count"],"groupBy":["level"],"period":"24h","orderBy":"count","orderDir":"desc","limit":10}`
|
|
status, got := call(t, app, member(http.MethodPost, "/v1/o11y/sentinel/discover", strings.NewReader(sent)))
|
|
if status != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s, want 200", status, got)
|
|
}
|
|
if !bytes.Equal(got, *wrote) {
|
|
t.Fatalf("the op changed the bytes.\n runtime: %s\n op: %s", *wrote, got)
|
|
}
|
|
|
|
forwarded, _ := io.ReadAll((*asked).Body)
|
|
var want, have sentrytypes.DiscoverRequest
|
|
if err := json.Unmarshal([]byte(sent), &want); err != nil {
|
|
t.Fatalf("unmarshal sent: %v", err)
|
|
}
|
|
if err := json.Unmarshal(forwarded, &have); err != nil {
|
|
t.Fatalf("the runtime was sent something it cannot read: %v (%s)", err, forwarded)
|
|
}
|
|
if a, b := mustJSON(t, want), mustJSON(t, have); a != b {
|
|
t.Fatalf("the op rewrote the request.\n caller: %s\n runtime: %s", a, b)
|
|
}
|
|
}
|
|
|
|
func mustJSON(t *testing.T, v any) string {
|
|
t.Helper()
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// The caller's identity travels on as the gateway asserted it — propagated, not
|
|
// minted, and not invented when there is none.
|
|
func TestIdentityIsPropagated(t *testing.T) {
|
|
app := mounted(t)
|
|
_, asked := runtime(t, map[string]any{"items": []*sentrytypes.Event{}})
|
|
|
|
r := member(http.MethodGet, "/v1/o11y/sentinel/logs?project=p1", nil)
|
|
r.Header.Set(zip.HeaderUserAdmin, "true")
|
|
r.Header.Set(zip.HeaderProject, "proj-9")
|
|
if status, body := call(t, app, r); status != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", status, body)
|
|
}
|
|
got := (*asked).Header
|
|
for header, want := range map[string]string{
|
|
zip.HeaderOrg: "maxpower",
|
|
zip.HeaderUser: "z",
|
|
zip.HeaderUserEmail: "[email protected]",
|
|
zip.HeaderUserAdmin: "true",
|
|
zip.HeaderProject: "proj-9",
|
|
} {
|
|
if got.Get(header) != want {
|
|
t.Errorf("%s reached the runtime as %q, want %q", header, got.Get(header), want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A refusal keeps the runtime's status and its reason. Both shapes this face
|
|
// answers with are read: the runtime's own error envelope and the org gate's.
|
|
func TestRefusalKeepsTheRuntimeStatus(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, body, want string
|
|
status int
|
|
}{
|
|
{"gate", `{"status":"error","msg":"an org-scoped principal is required"}`, "an org-scoped principal is required", http.StatusForbidden},
|
|
{"runtime", `{"status":"error","error":{"code":"sentry_invalid_input","message":"a valid project query param is required"}}`, "a valid project query param is required", http.StatusBadRequest},
|
|
{"notfound", `{"status":"error","error":{"code":"sentry_not_found","message":"event not found"}}`, "event not found", http.StatusNotFound},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
app := mounted(t)
|
|
o11y.SetRuntime(o11y.Whole(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(tc.status)
|
|
_, _ = io.WriteString(w, tc.body)
|
|
})))
|
|
t.Cleanup(func() { o11y.SetRuntime(nil) })
|
|
|
|
status, got := call(t, app, member(http.MethodGet, "/v1/o11y/sentinel/logs?project=p1", nil))
|
|
if status != tc.status {
|
|
t.Fatalf("status=%d, want %d (the runtime's own)", status, tc.status)
|
|
}
|
|
if !strings.Contains(string(got), tc.want) {
|
|
t.Fatalf("the reason was lost: %s", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// No runtime, no answer: the ops fail closed with the same 503 the delegation
|
|
// wildcard gives when nothing has been registered yet.
|
|
func TestTelemetryFailsClosedWithoutARuntime(t *testing.T) {
|
|
app := mounted(t)
|
|
o11y.SetRuntime(nil)
|
|
|
|
for _, target := range []string{
|
|
"/v1/o11y/sentinel/logs?project=p1",
|
|
"/v1/o11y/sentinel/traces?project=p1",
|
|
"/v1/o11y/sentinel/traces/t1?project=p1",
|
|
"/v1/o11y/sentinel/stats?project=p1",
|
|
} {
|
|
if status, body := call(t, app, member(http.MethodGet, target, nil)); status != http.StatusServiceUnavailable {
|
|
t.Errorf("%s: status=%d body=%s, want 503", target, status, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A missing project is refused before anything is read — the same 400 the
|
|
// runtime answers, from the one place the input is now declared mandatory.
|
|
func TestProjectIsMandatory(t *testing.T) {
|
|
app := mounted(t)
|
|
runtime(t, map[string]any{"items": []*sentrytypes.Event{}})
|
|
|
|
if status, body := call(t, app, member(http.MethodGet, "/v1/o11y/sentinel/logs", nil)); status != http.StatusBadRequest {
|
|
t.Fatalf("status=%d body=%s, want 400", status, body)
|
|
}
|
|
}
|
|
|
|
// THE ROUTES, exactly as they were. Five paths, five methods; the router's own
|
|
// spelling of a parameter differs from the mux tree's, the wire path does not.
|
|
//
|
|
// THE FACE CARRIES NO INGEST. The two ingest routes used to land in this census
|
|
// as well, and that is the shape this asserts against coming back: an
|
|
// unauthenticated write registered inside a principal-gated subtree means the
|
|
// gate has to carry an exemption shaped like it, and everything else under the
|
|
// root has to be checked against that exemption. The routes answer on /v1/event
|
|
// now (mount.go mountHatches, counted in routes_test.go), so everything here is
|
|
// a read — and TestTheFaceIsGatedWholesale below states that as a property
|
|
// rather than leaving it to this closed list.
|
|
func TestTelemetryRoutesAreTheSameFive(t *testing.T) {
|
|
app := mounted(t)
|
|
want := map[string]bool{
|
|
"POST /v1/o11y/sentinel/discover": true,
|
|
"GET /v1/o11y/sentinel/logs": true,
|
|
"GET /v1/o11y/sentinel/traces": true,
|
|
"GET /v1/o11y/sentinel/traces/:id": true,
|
|
"GET /v1/o11y/sentinel/stats": true,
|
|
}
|
|
got := map[string]bool{}
|
|
for _, r := range app.Fiber().GetRoutes(true) {
|
|
// /v1/o11y/sentinel is shared: THIS face owns the telemetry reads (discover, logs,
|
|
// traces, trace/:id, stats); sentryerrors.go owns projects and issues/*.
|
|
if strings.HasPrefix(r.Path, "/v1/o11y/sentinel") &&
|
|
!strings.Contains(r.Path, "/projects") && !strings.Contains(r.Path, "/issues") &&
|
|
!strings.Contains(r.Path, "/events/") &&
|
|
r.Method != http.MethodHead && r.Method != http.MethodOptions {
|
|
got[r.Method+" "+r.Path] = true
|
|
}
|
|
}
|
|
for route := range want {
|
|
if !got[route] {
|
|
t.Errorf("%s is not registered", route)
|
|
}
|
|
}
|
|
for route := range got {
|
|
if !want[route] {
|
|
t.Errorf("%s is registered and was not before — the face grew a route", route)
|
|
}
|
|
}
|
|
}
|
|
|
|
// NOTHING ON THE FACE IS EXEMPT FROM THE PRINCIPAL GATE. The set above is a
|
|
// closed list and would catch this instance; this states the PROPERTY, so a
|
|
// route added to both the face and the list still fails. The face is read with
|
|
// a session — an exempt write here is an unauthenticated route inside a gated
|
|
// subtree, and the exemption that admits it is what every sibling then has to
|
|
// be checked against.
|
|
func TestTheFaceIsGatedWholesale(t *testing.T) {
|
|
for route := range registered(t, mounted(t)) {
|
|
method, path, _ := strings.Cut(route, " ")
|
|
if strings.HasPrefix(path, "/v1/o11y/sentinel") && o11y.Anonymous(method, path) {
|
|
t.Errorf("%s is on the face and answers a caller with no principal", route)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ingest reaches the runtime through its OWN named route, path
|
|
// untouched, while the typed reads on the face dispatch as ops.
|
|
func TestTheIngestDoorIsANamedHatch(t *testing.T) {
|
|
app := mounted(t)
|
|
|
|
var saw string
|
|
o11y.SetRuntime(o11y.Whole(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
saw = r.URL.Path
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"id":"e1"}`)
|
|
})))
|
|
t.Cleanup(func() { o11y.SetRuntime(nil) })
|
|
|
|
const target = "/v1/event/6ba7b810-9dad-11d1-80b4-00c04fd430c8/envelope/"
|
|
if status, body := call(t, app, member(http.MethodPost, target, strings.NewReader("{}"))); status != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", status, body)
|
|
}
|
|
if saw != target {
|
|
t.Fatalf("runtime received %q, want %q verbatim", saw, target)
|
|
}
|
|
|
|
// ...and the typed reads next to it still dispatch as ops.
|
|
runtime(t, map[string]any{"items": []*sentrytypes.Event{}})
|
|
if status, body := call(t, app, member(http.MethodGet, "/v1/o11y/sentinel/logs?project=p1", nil)); status != http.StatusOK {
|
|
t.Fatalf("the typed op did not answer: status=%d %s", status, body)
|
|
}
|
|
}
|
|
|
|
// THE POINT OF THE PORT: the five reads are in the document now, each with its
|
|
// operation id, its prose and its inputs. A route behind the wildcard had none
|
|
// of that — no SDK method, no command, no agent tool, no reference page — which
|
|
// is what made a customer's own error telemetry unreachable from anything but a
|
|
// hand-written call.
|
|
func TestTelemetryReachesTheDocument(t *testing.T) {
|
|
app := mounted(t)
|
|
raw, err := json.Marshal(app.OpenAPISpec())
|
|
if err != nil {
|
|
t.Fatalf("spec: %v", err)
|
|
}
|
|
var spec struct {
|
|
Paths map[string]map[string]struct {
|
|
OperationID string `json:"operationId"`
|
|
Summary string `json:"summary"`
|
|
Parameters []struct {
|
|
Name string `json:"name"`
|
|
In string `json:"in"`
|
|
Required bool `json:"required"`
|
|
} `json:"parameters"`
|
|
} `json:"paths"`
|
|
}
|
|
if err := json.Unmarshal(raw, &spec); err != nil {
|
|
t.Fatalf("spec: %v", err)
|
|
}
|
|
|
|
for path, method := range map[string]string{
|
|
"/v1/o11y/sentinel/discover": "post",
|
|
"/v1/o11y/sentinel/logs": "get",
|
|
"/v1/o11y/sentinel/traces": "get",
|
|
"/v1/o11y/sentinel/traces/{id}": "get",
|
|
"/v1/o11y/sentinel/stats": "get",
|
|
} {
|
|
op, ok := spec.Paths[path][method]
|
|
if !ok {
|
|
t.Errorf("%s %s is not in the document", strings.ToUpper(method), path)
|
|
continue
|
|
}
|
|
if op.OperationID == "" {
|
|
t.Errorf("%s has no operation id, so nothing can name it", path)
|
|
}
|
|
// The summary is the FIRST SENTENCE of the handler's doc comment, lifted
|
|
// by zipdoc. An empty one means the prose never left the source.
|
|
if len(op.Summary) < 20 {
|
|
t.Errorf("%s has no prose in the document: %q", path, op.Summary)
|
|
}
|
|
if method == "get" {
|
|
var required bool
|
|
for _, p := range op.Parameters {
|
|
if p.Name == "project" && p.In == "query" && p.Required {
|
|
required = true
|
|
}
|
|
}
|
|
if !required {
|
|
t.Errorf("%s does not declare its mandatory project input", path)
|
|
}
|
|
}
|
|
}
|
|
}
|