more otel
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 58s

This commit is contained in:
mats committed 2026-09-18 20:51:37 +00:00
1 parent 9620e3bfa6
commit 21f04d696b
88 files changed
+23419 -192

No files matched your search

+355 -6
View File
@@ -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})
}