more otel
This commit is contained in:
1 parent
9620e3bfa6
commit
21f04d696b
88 files changed
+23419
-192
No files matched your search
@@ -4,7 +4,9 @@ package collector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-otel/internal/issues"
|
||||
"git.tornberg.me/go-otel/internal/storage"
|
||||
"git.tornberg.me/go-otel/internal/websocket"
|
||||
"go.opentelemetry.io/collector/pdata/plog"
|
||||
@@ -378,35 +381,282 @@ func (c *Collector) HandleIssues(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
list := c.ListIssues(parseLimit(r, 100))
|
||||
opts, err := parseIssueListOptions(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
page := c.ListIssuesFiltered(opts)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(list); err != nil {
|
||||
if page.HasMore && len(page.Issues) > 0 {
|
||||
last := page.Issues[len(page.Issues)-1]
|
||||
next := encodeIssueCursor(last.LastSeen, last.ID)
|
||||
w.Header().Set("X-Next-Cursor", next)
|
||||
nextQuery := r.URL.Query()
|
||||
nextQuery.Set("cursor", next)
|
||||
w.Header().Set("Link", fmt.Sprintf("<%s?%s>; rel=next", r.URL.Path, nextQuery.Encode()))
|
||||
}
|
||||
// Keep the response as a JSON array for compatibility. Pagination metadata
|
||||
// is exposed in headers so existing UI clients do not need a migration.
|
||||
if err := json.NewEncoder(w).Encode(page.Issues); err != nil {
|
||||
log.Printf("failed to encode issues: %v", err)
|
||||
http.Error(w, "failed to encode issues", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const maxIssuePageSize = 200
|
||||
|
||||
type issueCursor struct {
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func parseIssueListOptions(r *http.Request) (issues.ListOptions, error) {
|
||||
q := r.URL.Query()
|
||||
opts := issues.ListOptions{
|
||||
Service: q.Get("service"),
|
||||
Kind: q.Get("kind"),
|
||||
Status: q.Get("status"),
|
||||
Title: q.Get("q"),
|
||||
SourceIP: q.Get("sourceIp"),
|
||||
Route: q.Get("route"),
|
||||
Limit: parseLimit(r, 100),
|
||||
}
|
||||
if opts.Limit <= 0 {
|
||||
opts.Limit = 100
|
||||
}
|
||||
if opts.Limit > maxIssuePageSize {
|
||||
opts.Limit = maxIssuePageSize
|
||||
}
|
||||
for name, dst := range map[string]*int64{"since": &opts.Since, "until": &opts.Until} {
|
||||
if value := q.Get(name); value != "" {
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
return issues.ListOptions{}, fmt.Errorf("invalid %s; expected unix nanoseconds", name)
|
||||
}
|
||||
*dst = parsed
|
||||
}
|
||||
}
|
||||
if opts.Since != 0 && opts.Until != 0 && opts.Since > opts.Until {
|
||||
return issues.ListOptions{}, fmt.Errorf("since must be less than or equal to until")
|
||||
}
|
||||
if value := q.Get("cursor"); value != "" {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return issues.ListOptions{}, fmt.Errorf("invalid cursor")
|
||||
}
|
||||
var cursor issueCursor
|
||||
if err := json.Unmarshal(decoded, &cursor); err != nil || cursor.LastSeen <= 0 || cursor.ID == "" {
|
||||
return issues.ListOptions{}, fmt.Errorf("invalid cursor")
|
||||
}
|
||||
opts.AfterLastSeen = cursor.LastSeen
|
||||
opts.AfterID = cursor.ID
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func encodeIssueCursor(lastSeen int64, id string) string {
|
||||
data, _ := json.Marshal(issueCursor{LastSeen: lastSeen, ID: id})
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// HandleSourceMap accepts a JavaScript source map for a deployed release.
|
||||
// Multipart fields: release, source, file.
|
||||
func (c *Collector) HandleSourceMap(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if c.sourceMaps == nil {
|
||||
http.Error(w, "source maps are not configured", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(50 << 20); err != nil {
|
||||
http.Error(w, "invalid multipart body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
release, source := r.FormValue("release"), r.FormValue("source")
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "file is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if err := c.sourceMaps.Upload(release, source, file); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
// HandleIssueByID returns a single issue by id, including its samples.
|
||||
func (c *Collector) HandleIssueByID(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
issue, ok := c.GetIssue(id)
|
||||
if !ok {
|
||||
http.Error(w, "issue not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(issue); err != nil {
|
||||
log.Printf("failed to encode issue: %v", err)
|
||||
http.Error(w, "failed to encode issue", http.StatusInternalServerError)
|
||||
}
|
||||
case http.MethodPatch:
|
||||
c.HandleIssueStatus(w, r, id)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePatterns returns deterministic related-issue groups for visualization.
|
||||
func (c *Collector) HandlePatterns(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(c.ListPatterns())
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
issue, ok := c.GetIssue(id)
|
||||
// HandleRollouts returns normalized Kubernetes Deployment rollout history.
|
||||
func (c *Collector) HandleRollouts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !c.KubernetesEnabled() {
|
||||
jsonError(w, http.StatusNotImplemented, c.KubernetesError())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(c.ListRollouts())
|
||||
}
|
||||
|
||||
func (c *Collector) HandleRolloutByID(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !c.KubernetesEnabled() {
|
||||
jsonError(w, http.StatusNotImplemented, c.KubernetesError())
|
||||
return
|
||||
}
|
||||
rollout, ok := c.GetRollout(r.PathValue("id"))
|
||||
if !ok {
|
||||
http.Error(w, "rollout not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(rollout)
|
||||
}
|
||||
|
||||
func (c *Collector) HandleRolloutImpact(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !c.KubernetesEnabled() {
|
||||
jsonError(w, http.StatusNotImplemented, c.KubernetesError())
|
||||
return
|
||||
}
|
||||
rollout, ok := c.GetRollout(r.PathValue("id"))
|
||||
if !ok {
|
||||
http.Error(w, "rollout not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
response := map[string]interface{}{"configured": c.prometheus != nil, "rollout": rollout, "points": []interface{}{}}
|
||||
if c.prometheus != nil {
|
||||
start := time.Unix(0, rollout.StartedAt).Add(-15 * time.Minute)
|
||||
end := time.Now()
|
||||
if start.Before(end.Add(-24 * time.Hour)) {
|
||||
start = end.Add(-24 * time.Hour)
|
||||
}
|
||||
points, err := c.prometheus.ErrorRate(r.Context(), rollout.Service, rollout.Release, start, end)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
response["points"] = points
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func jsonError(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
|
||||
// HandleIssueSimilar returns explainable, non-merging related-issue suggestions.
|
||||
func (c *Collector) HandleIssueSimilar(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
limit := parseLimit(r, 10)
|
||||
result, ok := c.SimilarIssues(r.PathValue("id"), limit)
|
||||
if !ok {
|
||||
http.Error(w, "issue not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
// HandleSimilarityFeedback records a human review label for a pair of issues.
|
||||
// Body: {"issueId":"...", "decision":"same|different"}.
|
||||
func (c *Collector) HandleSimilarityFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
IssueID string `json:"issueId"`
|
||||
Decision string `json:"decision"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !c.SetSimilarityFeedback(r.PathValue("id"), req.IssueID, req.Decision) {
|
||||
http.Error(w, "invalid issue pair or decision", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "recorded", "decision": req.Decision})
|
||||
}
|
||||
|
||||
// HandleIssueStatus updates the triage state (open/resolved/ignored) of an
|
||||
// issue. Body: {"status": "resolved"}.
|
||||
func (c *Collector) HandleIssueStatus(w http.ResponseWriter, r *http.Request, id string) {
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
issue, ok := c.SetIssueStatus(id, req.Status)
|
||||
if !ok {
|
||||
http.Error(w, "issue not found or invalid status", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(issue); err != nil {
|
||||
log.Printf("failed to encode issue: %v", err)
|
||||
http.Error(w, "failed to encode issue", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,3 +668,102 @@ func (c *Collector) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("failed to encode health check: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// statsEnabled reports whether an aggregate-capable store is configured.
|
||||
func (c *Collector) statsEnabled() bool { return c.statsQuerier != nil }
|
||||
|
||||
// statsNotEnabled writes the response used when the file store is active:
|
||||
// a stable 501 the UI treats as "view not available".
|
||||
func statsNotEnabled(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "stats require OTEL_CLICKHOUSE_ADDR (ClickHouse storage) to be configured",
|
||||
})
|
||||
}
|
||||
|
||||
// parseStatsCommon reads the shared service/hours params for stats endpoints.
|
||||
func parseStatsCommon(r *http.Request) (service string, hours int) {
|
||||
service = r.URL.Query().Get("service")
|
||||
hours = 72
|
||||
if h, err := strconv.Atoi(r.URL.Query().Get("hours")); err == nil && h > 0 && h <= 24*90 {
|
||||
hours = h
|
||||
}
|
||||
return service, hours
|
||||
}
|
||||
|
||||
// HandleStatsErrors returns hourly/daily total and error counts for the
|
||||
// trends graphs. Only available with ClickHouse storage.
|
||||
func (c *Collector) HandleStatsErrors(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !c.statsEnabled() {
|
||||
statsNotEnabled(w)
|
||||
return
|
||||
}
|
||||
|
||||
service, hours := parseStatsCommon(r)
|
||||
daily := hours > 24*14 // beyond two weeks, daily buckets read better
|
||||
points, err := c.statsQuerier.ErrorStats(r.Context(), service, hours, daily)
|
||||
if err != nil {
|
||||
log.Printf("failed to compute error stats: %v", err)
|
||||
http.Error(w, "failed to compute error stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"granularity": map[bool]string{true: "day", false: "hour"}[daily],
|
||||
"points": points,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleStatsReleases returns per-release error-count time series so two
|
||||
// deploys can be compared line-by-line. Only available with ClickHouse.
|
||||
func (c *Collector) HandleStatsReleases(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !c.statsEnabled() {
|
||||
statsNotEnabled(w)
|
||||
return
|
||||
}
|
||||
|
||||
service, hours := parseStatsCommon(r)
|
||||
series, err := c.statsQuerier.ReleaseSeries(r.Context(), service, hours)
|
||||
if err != nil {
|
||||
log.Printf("failed to compute release stats: %v", err)
|
||||
http.Error(w, "failed to compute release stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"series": series,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleStatsServices lists the distinct services present in the rollup.
|
||||
func (c *Collector) HandleStatsServices(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !c.statsEnabled() {
|
||||
statsNotEnabled(w)
|
||||
return
|
||||
}
|
||||
|
||||
services, err := c.statsQuerier.Services(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("failed to list services: %v", err)
|
||||
http.Error(w, "failed to list services", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"services": services})
|
||||
}
|
||||
Reference in new issue
Block a user