ingotctl, HTTP layer, and self-instrumentation
Add cmd/ingotctl with four subcommands: blocks (list with stats), inspect (series/postings dump), chunks (decode raw samples by ref), and fsck (CRC and index integrity validation across all blocks). Add cmd/ingothttp with JSON query endpoints: /api/v1/query_range (Prometheus-style matrix response), /api/v1/read (matcher-based read requests), and /epi/v1/status (DB stats snapshot). Uses JSON instead of protobuf to maintain zero dependencies. Add self-instrumentation via the normal Appender path so metrics are queryable with the same API: ingot_head_series, ingot_head_chunks_active, ingot_blocks_total, ingot_compactions_total, ingot_wal_fsync_duration_seconds. Supporting changes: block.Validate() and block.ReadMeta() exports, Head.Stats() for series/chunk counts, WAL.LastSyncDuration() with timed fsync tracking, DB.Stats() for the HTTP status endpoint.
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.dvdt.dev/david/ingot"
|
||||
"git.dvdt.dev/david/ingot/labels"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
db *ingot.DB
|
||||
}
|
||||
|
||||
func newHandler(db *ingot.DB) *handler {
|
||||
return &handler{db: db}
|
||||
}
|
||||
|
||||
// --- /api/v1/query_range ---
|
||||
|
||||
// queryRange handles GET /api/v1/query_range?query=<name>&start=<ms>&end=<ms>
|
||||
// Returns Prometheus-style JSON matrix results.
|
||||
func (h *handler) queryRange(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
httpError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query().Get("query")
|
||||
if query == "" {
|
||||
httpError(w, http.StatusBadRequest, "missing query parameter")
|
||||
return
|
||||
}
|
||||
|
||||
startStr := r.URL.Query().Get("start")
|
||||
endStr := r.URL.Query().Get("end")
|
||||
|
||||
mint := int64(math.MinInt64)
|
||||
maxt := int64(math.MaxInt64)
|
||||
|
||||
if startStr != "" {
|
||||
v, err := strconv.ParseInt(startStr, 10, 64)
|
||||
if err != nil {
|
||||
httpError(w, http.StatusBadRequest, "invalid start: "+err.Error())
|
||||
return
|
||||
}
|
||||
mint = v
|
||||
}
|
||||
if endStr != "" {
|
||||
v, err := strconv.ParseInt(endStr, 10, 64)
|
||||
if err != nil {
|
||||
httpError(w, http.StatusBadRequest, "invalid end: "+err.Error())
|
||||
return
|
||||
}
|
||||
maxt = v
|
||||
}
|
||||
|
||||
matcher := labels.MustNewMatcher(labels.MatchEqual, "__name__", query)
|
||||
result, err := h.executeQuery(mint, maxt, []*labels.Matcher{matcher})
|
||||
if err != nil {
|
||||
httpError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := queryRangeResponse{
|
||||
Status: "success",
|
||||
Data: queryRangeData{
|
||||
ResultType: "matrix",
|
||||
Result: result,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// --- /api/v1/read ---
|
||||
|
||||
// ReadRequest is a JSON-encoded query for the read endpoint.
|
||||
type ReadRequest struct {
|
||||
Queries []ReadQuery `json:"queries"`
|
||||
}
|
||||
|
||||
// ReadQuery describes a single read query.
|
||||
type ReadQuery struct {
|
||||
StartTimestampMs int64 `json:"startTimestampMs"`
|
||||
EndTimestampMs int64 `json:"endTimestampMs"`
|
||||
Matchers []ReadMatcher `json:"matchers"`
|
||||
}
|
||||
|
||||
// ReadMatcher is a label matcher in a read request.
|
||||
type ReadMatcher struct {
|
||||
Type string `json:"type"` // "=", "!=", "=~", "!~"
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// ReadResponse is the JSON response for the read endpoint.
|
||||
type ReadResponse struct {
|
||||
Results []ReadResult `json:"results"`
|
||||
}
|
||||
|
||||
// ReadResult contains the timeseries for a single query.
|
||||
type ReadResult struct {
|
||||
Timeseries []timeseriesResult `json:"timeseries"`
|
||||
}
|
||||
|
||||
// read handles POST /api/v1/read with a JSON ReadRequest body.
|
||||
func (h *handler) read(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
httpError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
var req ReadRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := ReadResponse{
|
||||
Results: make([]ReadResult, len(req.Queries)),
|
||||
}
|
||||
|
||||
for i, rq := range req.Queries {
|
||||
matchers, err := convertMatchers(rq.Matchers)
|
||||
if err != nil {
|
||||
httpError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.executeQuery(rq.StartTimestampMs, rq.EndTimestampMs, matchers)
|
||||
if err != nil {
|
||||
httpError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ts := make([]timeseriesResult, len(result))
|
||||
for j, sr := range result {
|
||||
ts[j] = timeseriesResult{
|
||||
Labels: sr.Metric,
|
||||
Samples: sr.Values,
|
||||
}
|
||||
}
|
||||
resp.Results[i] = ReadResult{Timeseries: ts}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// --- /api/v1/status ---
|
||||
|
||||
type statusResponse struct {
|
||||
HeadSeries int `json:"headSeries"`
|
||||
HeadChunks int `json:"headChunks"`
|
||||
Blocks int `json:"blocks"`
|
||||
Compactions int `json:"compactions"`
|
||||
}
|
||||
|
||||
func (h *handler) status(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
httpError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
stats := h.db.Stats()
|
||||
resp := statusResponse{
|
||||
HeadSeries: stats.HeadSeries,
|
||||
HeadChunks: stats.HeadChunks,
|
||||
Blocks: stats.Blocks,
|
||||
Compactions: stats.Compactions,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// --- shared helpers ---
|
||||
|
||||
type queryRangeResponse struct {
|
||||
Status string `json:"status"`
|
||||
Data queryRangeData `json:"data"`
|
||||
}
|
||||
|
||||
type queryRangeData struct {
|
||||
ResultType string `json:"resultType"`
|
||||
Result []seriesResult `json:"result"`
|
||||
}
|
||||
|
||||
type seriesResult struct {
|
||||
Metric map[string]string `json:"metric"`
|
||||
Values [][2]interface{} `json:"values"` // [timestamp_ms, "value"]
|
||||
}
|
||||
|
||||
type timeseriesResult struct {
|
||||
Labels map[string]string `json:"labels"`
|
||||
Samples [][2]interface{} `json:"samples"`
|
||||
}
|
||||
|
||||
func (h *handler) executeQuery(mint, maxt int64, matchers []*labels.Matcher) ([]seriesResult, error) {
|
||||
q, err := h.db.Querier(mint, maxt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer q.Close()
|
||||
|
||||
ss := q.Select(matchers...)
|
||||
var results []seriesResult
|
||||
|
||||
for ss.Next() {
|
||||
s := ss.At()
|
||||
ls := s.Labels()
|
||||
metric := make(map[string]string, len(ls))
|
||||
for _, l := range ls {
|
||||
metric[l.Name] = l.Value
|
||||
}
|
||||
|
||||
var values [][2]interface{}
|
||||
it := s.Iterator()
|
||||
for it.Next() {
|
||||
t, v := it.At()
|
||||
values = append(values, [2]interface{}{t, fmt.Sprintf("%g", v)})
|
||||
}
|
||||
if it.Err() != nil {
|
||||
return nil, it.Err()
|
||||
}
|
||||
|
||||
if len(values) > 0 {
|
||||
results = append(results, seriesResult{
|
||||
Metric: metric,
|
||||
Values: values,
|
||||
})
|
||||
}
|
||||
}
|
||||
if ss.Err() != nil {
|
||||
return nil, ss.Err()
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func convertMatchers(rms []ReadMatcher) ([]*labels.Matcher, error) {
|
||||
matchers := make([]*labels.Matcher, len(rms))
|
||||
for i, rm := range rms {
|
||||
var matchType labels.MatchType
|
||||
switch rm.Type {
|
||||
case "=":
|
||||
matchType = labels.MatchEqual
|
||||
case "!=":
|
||||
matchType = labels.MatchNotEqual
|
||||
case "=~":
|
||||
matchType = labels.MatchRegexp
|
||||
case "!~":
|
||||
matchType = labels.MatchNotRegexp
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported matcher type: %q", rm.Type)
|
||||
}
|
||||
m, err := labels.NewMatcher(matchType, rm.Name, rm.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid matcher: %w", err)
|
||||
}
|
||||
matchers[i] = m
|
||||
}
|
||||
return matchers, nil
|
||||
}
|
||||
|
||||
func httpError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "error",
|
||||
"error": msg,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user