Files
zeekayandHanzo Dev cae99adb44 The relay reads the operation zip served
An op learns its address from zip.OpOf and zip.AddressOf, so the claim table,
the five declaring verbs and the address bookkeeping are gone. A host that
declares one of these addresses itself now gets a Build conflict from zip.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 15:02:30 -07:00

185 lines
11 KiB
Go

package o11yapiserver
import (
"net/http"
"github.com/hanzoai/o11y/pkg/http/handler"
"github.com/hanzoai/o11y/pkg/http/routing"
"github.com/hanzoai/o11y/pkg/types"
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
"github.com/hanzoai/o11y/pkg/types/sentrytypes"
)
// The ingest {project} path var is constrained to a UUID — {project:guid} — so a
// project segment is a UUID and nothing else, and a garbage segment is a 404 from the
// router rather than a body handed to the DSN verifier. The constraint is the router's
// own `guid` — a UUID parse, not a hand-written character class that has to be read to
// know what it accepts.
// addSentryRoutes registers TWO families, and they answer on two roots because they
// answer two different callers:
//
// - The FACE, /v1/o11y/sentinel — Hanzo Sentry, the Sentry-parity product read by a
// signed-in person: projects, issues, discover, events, logs, traces, stats. Hanzo
// IAM authz, every one scoped to the caller's org from the validated claims.
// - INGEST, POST /v1/event/{project}/envelope and /store — a keyed beacon. It is
// the address a minted DSN spells (implsentry's mintDSN), so the address a client
// is told and the address this router opens are one string. OpenAccess (no IAM):
// a Sentry SDK presents a DSN key, not a Hanzo session; the handler verifies that
// key against the project's rotation watermark.
//
// ONE WIRE, ONE PUBLIC ADDRESS. Ingest used to sit under the face's root as well,
// which put one wire at two addresses and put an unauthenticated write inside a
// principal-gated subtree — so the face's gate had to carry an exemption shaped like
// the ingest paths, and the exemption is what anything under that root had to be
// checked against. Off the face there is nothing to exempt.
//
// Both are literal paths on the SAME router the o11y read plane uses, so no
// /v1/o11y→/api rewrite applies (see createPublicServer: the path that arrives is the
// path that matches).
//
// FIVE of the read routes — discover, logs, traces, traces/{id}, stats — are ALSO
// declared as typed ops at the module's mount seam (telemetry.go in the repo root),
// which is what carries them into the composed document, the SDK, the CLI and the
// agent surface. That is a second DISPATCH, never a second implementation: the ops
// answer by handing the call to this router, so the handlers below stay the one
// place the reads are performed. Both are needed — the ops serve the composed
// binary, this router serves the standalone process, which has no native router to
// register an op on — so deleting either half drops one of the two deployments.
//
// The OTHER ten reads/writes of this face — projects (list/create/get/delete/
// rotate-key), issues (list/get/update/events) and one event — are ALSO typed ops
// at that seam (sentryerrors.go), the same second DISPATCH into this router. The two
// INGEST routes below are named at that seam as HATCHES rather than ops (mount.go's
// mountHatches): OpenAccess, DSN-authenticated, carrying an opaque Sentry-envelope
// body a typed relay would corrupt, so they are named but not typed and stay out of
// the document by design.
func (provider *provider) addSentryRoutes(router routing.Router) {
h := provider.sentryHandler
// The FACE.
staticRoutes := []struct {
method string
path string
fn http.HandlerFunc
def handler.OpenAPIDef
}{
{http.MethodGet, "/v1/o11y/sentinel/projects", provider.authzMiddleware.ViewAccess(h.ListProjects), handler.OpenAPIDef{
ID: "SentryListProjects", Tags: []string{"sentry"}, Summary: "List Sentry projects",
Response: new(sentrytypes.GettableProjects), ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK, SecuritySchemes: newSecuritySchemes(types.RoleViewer),
}},
{http.MethodPost, "/v1/o11y/sentinel/projects", provider.authzMiddleware.EditAccess(h.CreateProject), handler.OpenAPIDef{
ID: "SentryCreateProject", Tags: []string{"sentry"}, Summary: "Create a Sentry project",
Request: new(sentrytypes.PostableProject), RequestContentType: "application/json",
Response: new(sentrytypes.GettableProject), ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK, ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
}},
{http.MethodGet, "/v1/o11y/sentinel/projects/{id}", provider.authzMiddleware.ViewAccess(h.GetProject), handler.OpenAPIDef{
ID: "SentryGetProject", Tags: []string{"sentry"}, Summary: "Get a Sentry project",
Response: new(sentrytypes.GettableProject), ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK, ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
}},
{http.MethodDelete, "/v1/o11y/sentinel/projects/{id}", provider.authzMiddleware.EditAccess(h.DeleteProject), handler.OpenAPIDef{
ID: "SentryDeleteProject", Tags: []string{"sentry"}, Summary: "Delete a Sentry project",
SuccessStatusCode: http.StatusNoContent, ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
}},
{http.MethodPost, "/v1/o11y/sentinel/projects/{id}/keys/rotate", provider.authzMiddleware.EditAccess(h.RotateProjectKey), handler.OpenAPIDef{
ID: "SentryRotateProjectKey", Tags: []string{"sentry"}, Summary: "Rotate a project's DSN key",
Response: new(sentrytypes.GettableProject), ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK, ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
}},
{http.MethodGet, "/v1/o11y/sentinel/issues", provider.authzMiddleware.ViewAccess(h.ListIssues), handler.OpenAPIDef{
ID: "SentryListIssues", Tags: []string{"sentry"}, Summary: "List error issues",
RequestQuery: new(errortrackingtypes.IssuesQuery), Response: new(errortrackingtypes.GettableIssues),
ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
}},
{http.MethodGet, "/v1/o11y/sentinel/issues/{id}", provider.authzMiddleware.ViewAccess(h.GetIssue), handler.OpenAPIDef{
ID: "SentryGetIssue", Tags: []string{"sentry"}, Summary: "Get an error issue",
Response: new(errortrackingtypes.GettableIssue), ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK, ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
}},
{http.MethodPut, "/v1/o11y/sentinel/issues/{id}", provider.authzMiddleware.EditAccess(h.UpdateIssue), handler.OpenAPIDef{
ID: "SentryUpdateIssue", Tags: []string{"sentry"}, Summary: "Update an issue's lifecycle",
Request: new(errortrackingtypes.UpdateIssue), RequestContentType: "application/json",
Response: new(errortrackingtypes.Issue), ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK, ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
}},
{http.MethodGet, "/v1/o11y/sentinel/issues/{id}/events", provider.authzMiddleware.ViewAccess(h.IssueEvents), handler.OpenAPIDef{
ID: "SentryIssueEvents", Tags: []string{"sentry"}, Summary: "List an issue's occurrences",
ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
}},
{http.MethodPost, "/v1/o11y/sentinel/discover", provider.authzMiddleware.ViewAccess(h.Discover), handler.OpenAPIDef{
ID: "SentryDiscover", Tags: []string{"sentry"}, Summary: "Query the events plane",
Request: new(sentrytypes.DiscoverRequest), RequestContentType: "application/json",
Response: new(sentrytypes.DiscoverResult), ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK, ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
}},
{http.MethodGet, "/v1/o11y/sentinel/events/{id}", provider.authzMiddleware.ViewAccess(h.GetEvent), handler.OpenAPIDef{
ID: "SentryGetEvent", Tags: []string{"sentry"}, Summary: "Get one error event",
Response: new(sentrytypes.Event), ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK, ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
}},
{http.MethodGet, "/v1/o11y/sentinel/logs", provider.authzMiddleware.ViewAccess(h.ListLogs), handler.OpenAPIDef{
ID: "SentryListLogs", Tags: []string{"sentry"}, Summary: "List error-event logs",
ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest}, SecuritySchemes: newSecuritySchemes(types.RoleViewer),
}},
{http.MethodGet, "/v1/o11y/sentinel/traces", provider.authzMiddleware.ViewAccess(h.ListTraces), handler.OpenAPIDef{
ID: "SentryListTraces", Tags: []string{"sentry"}, Summary: "List error-correlated traces",
ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest}, SecuritySchemes: newSecuritySchemes(types.RoleViewer),
}},
{http.MethodGet, "/v1/o11y/sentinel/traces/{id}", provider.authzMiddleware.ViewAccess(h.GetTrace), handler.OpenAPIDef{
ID: "SentryGetTrace", Tags: []string{"sentry"}, Summary: "Get a trace waterfall",
ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound}, SecuritySchemes: newSecuritySchemes(types.RoleViewer),
}},
{http.MethodGet, "/v1/o11y/sentinel/stats", provider.authzMiddleware.ViewAccess(h.Stats), handler.OpenAPIDef{
ID: "SentryStats", Tags: []string{"sentry"}, Summary: "Event-rate timeseries",
ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest}, SecuritySchemes: newSecuritySchemes(types.RoleViewer),
}},
}
for _, rt := range staticRoutes {
router.Handle(rt.method, rt.path, handler.New(rt.fn, rt.def))
}
// INGEST — on its own root, UUID-constrained project segment.
ingestRoutes := []struct {
path string
fn http.HandlerFunc
def handler.OpenAPIDef
}{
{"/v1/event/{project:guid}/envelope", provider.authzMiddleware.OpenAccess(h.EnvelopeIngest), handler.OpenAPIDef{
ID: "SentryIngestEnvelope", Tags: []string{"sentry"}, Summary: "Ingest a Sentry envelope",
Description: "Sentry-envelope-compatible ingest. Authenticated by the DSN public key (X-Sentry-Auth or ?sentry_key), not a Hanzo session.",
RequestContentType: "application/x-sentry-envelope",
ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusServiceUnavailable},
SecuritySchemes: []handler.OpenAPISecurityScheme{},
}},
{"/v1/event/{project:guid}/store", provider.authzMiddleware.OpenAccess(h.StoreIngest), handler.OpenAPIDef{
ID: "SentryIngestStore", Tags: []string{"sentry"}, Summary: "Ingest a legacy Sentry store event",
Description: "Legacy single-event Sentry ingest. Authenticated by the DSN public key.",
RequestContentType: "application/json",
ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusServiceUnavailable},
SecuritySchemes: []handler.OpenAPISecurityScheme{},
}},
}
for _, rt := range ingestRoutes {
router.Post(rt.path, handler.New(rt.fn, rt.def))
}
}