docs/contributing/go/handler.md
Handlers in SigNoz are responsible for exposing module functionality over HTTP. They are thin adapters that:
They are not the place for complex business logic; that belongs in modules (for example, pkg/modules/user, pkg/modules/session, etc).
At a high level, a typical flow looks like this:
Handler interface is defined in the module (for example, user.Handler, session.Handler, organization.Handler).apiserver provider wires those handlers into HTTP routes using Gorilla mux.Router.Each route wraps a module handler method with the following:
pkg/http/middleware)handler.Handler (from pkg/http/handler)OpenAPIDef that describes the operation for OpenAPI generationFor example, in pkg/apiserver/signozapiserver:
if err := router.Handle("/api/v1/invite", handler.New(
provider.authZ.AdminAccess(provider.userHandler.CreateInvite),
handler.OpenAPIDef{
ID: "CreateInvite",
Tags: []string{"users"},
Summary: "Create invite",
Description: "This endpoint creates an invite for a user",
Request: new(types.PostableInvite),
RequestContentType: "application/json",
Response: new(types.Invite),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
In this pattern:
provider.userHandler.CreateInvite is a handler method.provider.authZ.AdminAccess(...) wraps that method with authorization checks and context setup.handler.New converts it into an HTTP handler and wires it to OpenAPI via the OpenAPIDef.When adding a new endpoint:
Handler interface.signozapiserver with the correct route, HTTP method, auth, and OpenAPIDef.Handler interface or create a new oneFind the module in pkg/modules/<name> and extend its Handler interface with a new method that receives an http.ResponseWriter and *http.Request. For example:
type Handler interface {
// existing methods...
CreateThing(rw http.ResponseWriter, req *http.Request)
}
Keep the method focused on HTTP concerns and delegate business logic to the module.
In the module implementation, implement the new method. A typical implementation:
req.Context()types.* struct using the binding packagerender package to write responses or errorsfunc (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
// Extract authentication and organization context from req.Context()
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
// Decode the request body into a `types.*` struct using the `binding` package
var in types.PostableThing
if err := binding.JSON.BindBody(req.Body, &in); err != nil {
render.Error(rw, err)
return
}
// Call module functions
out, err := h.module.CreateThing(req.Context(), claims.OrgID, &in)
if err != nil {
render.Error(rw, err)
return
}
// Use the `render` package to write responses or errors
render.Success(rw, http.StatusCreated, out)
}
signozapiserverIn pkg/apiserver/signozapiserver, add a route in the appropriate add*Routes function (addUserRoutes, addSessionRoutes, addOrgRoutes, etc.). The pattern is:
if err := router.Handle("/api/v1/things", handler.New(
provider.authZ.AdminAccess(provider.thingHandler.CreateThing),
handler.OpenAPIDef{
ID: "CreateThing",
Tags: []string{"things"},
Summary: "Create thing",
Description: "This endpoint creates a thing",
Request: new(types.PostableThing),
RequestContentType: "application/json",
RequestQuery: new(types.QueryableThing),
Response: new(types.GettableThing),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
Run the following command to update the OpenAPI spec:
go run cmd/enterprise/*.go generate openapi
This will update the OpenAPI spec in docs/api/openapi.yml to reflect the new endpoint.
The handler.New function ties the HTTP handler to OpenAPI metadata via OpenAPIDef. This drives the generated OpenAPI document.
operationId)."users", "sessions", "orgs").Request is a Go type that describes the request body or form.RequestContentType is usually "application/json" or "application/x-www-form-urlencoded" (for callbacks like SAML).RequestQuery is a Go type that descirbes query url params.handler.OpenAPIExample that provide concrete request payloads in the generated spec. See Adding request examples below.Response is the Go type for the successful response payload.ResponseContentType is usually "application/json"; use "" for responses without a body.http.StatusOK, http.StatusCreated, http.StatusNoContent).handler.New.The generic handler:
401, 403, and 500 to ErrorStatusCodes when appropriate.docs/api/openapi.yml.See existing examples in:
addUserRoutes (for typical JSON request/response)addSessionRoutes (for form-encoded and redirect flows)The OpenAPI spec is generated from the Go types you pass as Request and Response in OpenAPIDef. The following struct tags and interfaces control how those types appear in the generated schema.
Use the RequestExamples field in OpenAPIDef to provide concrete request payloads. Each example is a handler.OpenAPIExample:
type OpenAPIExample struct {
Name string // unique key for the example (e.g. "traces_time_series")
Summary string // short description shown in docs (e.g. "Time series: count spans grouped by service")
Description string // optional longer description
Value any // the example payload, typically map[string]any
}
For reference, see pkg/apiserver/signozapiserver/querier.go which defines examples inline for the /api/v5/query_range endpoint:
if err := router.Handle("/api/v5/query_range", handler.New(provider.authZ.ViewAccess(provider.querierHandler.QueryRange), handler.OpenAPIDef{
ID: "QueryRangeV5",
Tags: []string{"querier"},
Summary: "Query range",
Description: "Execute a composite query over a time range.",
Request: new(qbtypes.QueryRangeRequest),
RequestContentType: "application/json",
RequestExamples: []handler.OpenAPIExample{
{
Name: "traces_time_series",
Summary: "Time series: count spans grouped by service",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
// ...
},
},
},
},
},
},
// ... more examples
},
// ...
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
required tagUse required:"true" on struct fields where the property is expected to be present in the JSON payload. This is different from the zero value, a field can have its zero value (e.g. 0, "", false) and still be required. The required tag means the key itself must exist in the JSON object.
type ListItem struct {
...
}
type ListResponse struct {
List []ListItem `json:"list" required:"true" nullable:"true"`
Total uint64 `json:"total" required:"true"`
}
In this example, a response like {"list": null, "total": 0} is valid. Both keys are present (satisfying required), total has its zero value, and list is null (allowed by nullable). But {"total": 0} would violate the schema because the list key is missing.
nullable tagUse nullable:"true" on struct fields that can be null in the JSON payload. This is especially important for slice and map fields because in Go, the zero value for these types is nil, which serializes to null in JSON (not [] or {}).
Be explicit about the distinction:
nullable:"true"): the field can be null. Use this when the Go code may return nil for the slice.nullable tag): the field is always an array, never null. Ensure the Go code initializes it to an empty slice (e.g. make([]T, 0)) before serializing.// Non-nullable: Go code must ensure this is always an initialized slice.
type NonNullableExample struct {
Items []Item `json:"items" required:"true"`
}
When defining your types, ask yourself: "Can this field be null in the JSON response, or is it always an array/object?" If the Go code ever returns a nil slice or map, mark it nullable:"true".
Enum() methodFor types that have a fixed set of acceptable values, implement the Enum() []any method. This generates an enum constraint in the JSON schema so the OpenAPI spec accurately restricts the values.
type Signal struct {
valuer.String
}
var (
SignalTraces = Signal{valuer.NewString("traces")}
SignalLogs = Signal{valuer.NewString("logs")}
SignalMetrics = Signal{valuer.NewString("metrics")}
)
func (Signal) Enum() []any {
return []any{
SignalTraces,
SignalLogs,
SignalMetrics,
}
}
This produces the following in the generated OpenAPI spec:
Signal:
enum:
- traces
- logs
- metrics
type: string
Every type with a known set of values must implement Enum(). Without it, the JSON schema will only show the base type (e.g. string) with no value constraints.
JSONSchema() method (custom schema)For types that need a completely custom JSON schema (for example, a field that accepts either a string or a number), implement the jsonschema.Exposer interface:
var _ jsonschema.Exposer = Step{}
func (Step) JSONSchema() (jsonschema.Schema, error) {
s := jsonschema.Schema{}
s.WithDescription("Step interval. Accepts a duration string or seconds.")
strSchema := jsonschema.Schema{}
strSchema.WithType(jsonschema.String.Type())
strSchema.WithExamples("60s", "5m", "1h")
numSchema := jsonschema.Schema{}
numSchema.WithType(jsonschema.Number.Type())
numSchema.WithExamples(60, 300, 3600)
s.OneOf = []jsonschema.SchemaOrBool{
strSchema.ToSchemaOrBool(),
numSchema.ToSchemaOrBool(),
}
return s, nil
}
signozapiserver using handler.New and a complete OpenAPIDef.types packages so OpenAPI schemas are correct.required:"true" on fields where the key must be present in the JSON (this is about key presence, not about the zero value).nullable:"true" on fields that can be null. Pay special attention to slices and maps -- in Go these default to nil which serializes to null. If the field should always be an array, initialize it and do not mark it nullable.Enum() on every type that has a fixed set of acceptable values so the JSON schema generates proper enum constraints.RequestExamples in OpenAPIDef for any non-trivial endpoint. See pkg/apiserver/signozapiserver/querier.go for reference.