- Add internal/issues.Tracker: fingerprints (kind, service, title) into
deduplicated Issues with count/firstSeen/lastSeen/affected services and
bounded sample occurrences, evicting the least-recently-seen issue once
a cap is reached.
- Add internal/collector/issue_extract.go to pull issues out of ingested
OTLP data: spans with an error status (preferring exception.type/message
from an "exception" event, falling back to the span status message), and
logs at ERROR/FATAL severity (by severity number or text).
- Wire extraction into Collector.ConsumeTraces/ConsumeLogs and expose
GET /api/issues and GET /api/issues/{id}.
- Fix a pre-existing data race in the collector test's mockStore (unsynced
access to entries while addToHistory appends asynchronously), found via
`go test -race` while adding the new issue-tracking tests.
- Update docs/PLAN.md, README.md and agents_example.md for the new API.
The UI "Issues" tab itself lives in the separate frontend project that
builds this server's dist/ bundle; this commit only covers the backend.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
390 lines
12 KiB
Go
390 lines
12 KiB
Go
// Package collector implements an OpenTelemetry consumer that stores telemetry in history
|
|
// and broadcasts it to connected clients.
|
|
package collector
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.tornberg.me/go-otel/internal/storage"
|
|
"git.tornberg.me/go-otel/internal/websocket"
|
|
"go.opentelemetry.io/collector/pdata/plog"
|
|
"go.opentelemetry.io/collector/pdata/pmetric"
|
|
"go.opentelemetry.io/collector/pdata/ptrace"
|
|
)
|
|
|
|
// HandleTraces handles OTLP trace requests.
|
|
func (c *Collector) HandleTraces(w http.ResponseWriter, r *http.Request) {
|
|
log.Printf("Received traces request: method=%s, content-type=%s", r.Method, r.Header.Get("Content-Type"))
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
log.Printf("failed to read request body: %v", err)
|
|
http.Error(w, "failed to read request body", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
log.Printf("Received traces body, size=%d", len(body))
|
|
|
|
var unmarshaler ptrace.Unmarshaler
|
|
contentType := r.Header.Get("Content-Type")
|
|
switch contentType {
|
|
case "application/x-protobuf":
|
|
unmarshaler = &ptrace.ProtoUnmarshaler{}
|
|
case "application/json":
|
|
unmarshaler = &ptrace.JSONUnmarshaler{}
|
|
default:
|
|
unmarshaler = &ptrace.JSONUnmarshaler{} // default to JSON
|
|
}
|
|
|
|
traces, err := unmarshaler.UnmarshalTraces(body)
|
|
if err != nil {
|
|
log.Printf("failed to unmarshal traces: %v", err)
|
|
http.Error(w, "failed to unmarshal traces", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
log.Printf("Successfully unmarshaled traces, span_count=%d", traces.SpanCount())
|
|
|
|
if err := c.ConsumeTraces(context.Background(), traces); err != nil {
|
|
log.Printf("failed to consume traces: %v", err)
|
|
http.Error(w, "failed to consume traces", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// HandleMetrics handles OTLP metric requests.
|
|
func (c *Collector) HandleMetrics(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
log.Printf("failed to read request body: %v", err)
|
|
http.Error(w, "failed to read request body", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var unmarshaler pmetric.Unmarshaler
|
|
contentType := r.Header.Get("Content-Type")
|
|
switch contentType {
|
|
case "application/x-protobuf":
|
|
unmarshaler = &pmetric.ProtoUnmarshaler{}
|
|
case "application/json":
|
|
unmarshaler = &pmetric.JSONUnmarshaler{}
|
|
default:
|
|
unmarshaler = &pmetric.JSONUnmarshaler{} // default to JSON
|
|
}
|
|
|
|
metrics, err := unmarshaler.UnmarshalMetrics(body)
|
|
if err != nil {
|
|
log.Printf("failed to unmarshal metrics: %v", err)
|
|
http.Error(w, "failed to unmarshal metrics", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := c.ConsumeMetrics(context.Background(), metrics); err != nil {
|
|
log.Printf("failed to consume metrics: %v", err)
|
|
http.Error(w, "failed to consume metrics", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// HandleLogs handles OTLP log requests.
|
|
func (c *Collector) HandleLogs(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
log.Printf("failed to read request body: %v", err)
|
|
http.Error(w, "failed to read request body", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var unmarshaler plog.Unmarshaler
|
|
contentType := r.Header.Get("Content-Type")
|
|
switch contentType {
|
|
case "application/x-protobuf":
|
|
unmarshaler = &plog.ProtoUnmarshaler{}
|
|
case "application/json":
|
|
unmarshaler = &plog.JSONUnmarshaler{}
|
|
default:
|
|
unmarshaler = &plog.JSONUnmarshaler{} // default to JSON
|
|
}
|
|
|
|
logs, err := unmarshaler.UnmarshalLogs(body)
|
|
if err != nil {
|
|
log.Printf("failed to unmarshal logs: %v", err)
|
|
http.Error(w, "failed to unmarshal logs", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := c.ConsumeLogs(context.Background(), logs); err != nil {
|
|
log.Printf("failed to consume logs: %v", err)
|
|
http.Error(w, "failed to consume logs", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// parseSearchOptions builds a storage.SearchOptions from common query
|
|
// parameters shared by the search/wait/history endpoints: q, type, since, until.
|
|
func parseSearchOptions(r *http.Request) storage.SearchOptions {
|
|
q := r.URL.Query()
|
|
opts := storage.SearchOptions{
|
|
Query: q.Get("q"),
|
|
Type: q.Get("type"),
|
|
}
|
|
if since := q.Get("since"); since != "" {
|
|
if v, err := strconv.ParseInt(since, 10, 64); err == nil {
|
|
opts.Since = v
|
|
}
|
|
}
|
|
if until := q.Get("until"); until != "" {
|
|
if v, err := strconv.ParseInt(until, 10, 64); err == nil {
|
|
opts.Until = v
|
|
}
|
|
}
|
|
return opts
|
|
}
|
|
|
|
func parseLimit(r *http.Request, def int) int {
|
|
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil {
|
|
return l
|
|
}
|
|
}
|
|
return def
|
|
}
|
|
|
|
// HandleHistory returns the telemetry history as JSON. It searches the full
|
|
// persistent store when one is configured, so results are not bounded by the
|
|
// in-memory ring buffer size.
|
|
func (c *Collector) HandleHistory(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
history, err := c.SearchHistory(r.Context(), storage.SearchOptions{}, parseLimit(r, 100))
|
|
if err != nil {
|
|
log.Printf("failed to fetch history: %v", err)
|
|
http.Error(w, "failed to fetch history", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(history); err != nil {
|
|
log.Printf("failed to encode history: %v", err)
|
|
http.Error(w, "failed to encode history", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleSearch searches the telemetry history. Supports the free-text 'q'
|
|
// parameter plus structured filters: type, since, until (unix nanoseconds).
|
|
func (c *Collector) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
opts := parseSearchOptions(r)
|
|
history, err := c.SearchHistory(r.Context(), opts, parseLimit(r, 100))
|
|
if err != nil {
|
|
log.Printf("failed to search history: %v", err)
|
|
http.Error(w, "failed to search history", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(history); err != nil {
|
|
log.Printf("failed to encode search results: %v", err)
|
|
http.Error(w, "failed to encode history", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleWait waits for a specific telemetry signal, optionally restricted by
|
|
// the same structured filters as HandleSearch (type, since, until).
|
|
func (c *Collector) HandleWait(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if !c.waitLimiter.Allow(clientIP(r)) {
|
|
http.Error(w, "too many wait requests, slow down", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
opts := parseSearchOptions(r)
|
|
timeoutSec := 30
|
|
if t := r.URL.Query().Get("timeout"); t != "" {
|
|
if ts, err := strconv.Atoi(t); err == nil {
|
|
timeoutSec = ts
|
|
}
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutSec)*time.Second)
|
|
defer cancel()
|
|
|
|
// First, check if result already exists in recent history
|
|
matches, err := c.SearchHistory(ctx, opts, 1)
|
|
if err == nil && len(matches) > 0 {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(matches[0]); err != nil {
|
|
log.Printf("failed to encode match: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Otherwise, subscribe and wait
|
|
lowerQuery := strings.ToLower(opts.Query)
|
|
ch, cleanup := c.Subscribe(func(entry websocket.HistoryEntry) bool {
|
|
if opts.Type != "" && entry.Type != opts.Type {
|
|
return false
|
|
}
|
|
if opts.Query == "" {
|
|
return true
|
|
}
|
|
return strings.Contains(strings.ToLower(string(entry.Data)), lowerQuery)
|
|
})
|
|
defer cleanup()
|
|
|
|
select {
|
|
case entry := <-ch:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(entry); err != nil {
|
|
log.Printf("failed to encode signal: %v", err)
|
|
}
|
|
case <-ctx.Done():
|
|
http.Error(w, "timeout waiting for signal", http.StatusGatewayTimeout)
|
|
}
|
|
}
|
|
|
|
// HandleHistoryByType returns telemetry history filtered by type, searching
|
|
// the full persistent store when one is configured.
|
|
func (c *Collector) HandleHistoryByType(dataType string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
opts := storage.SearchOptions{Type: dataType}
|
|
history, err := c.SearchHistory(r.Context(), opts, parseLimit(r, 100))
|
|
if err != nil {
|
|
log.Printf("failed to fetch history for type %s: %v", dataType, err)
|
|
http.Error(w, "failed to fetch history", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(history); err != nil {
|
|
log.Printf("failed to encode filtered history: %v", err)
|
|
http.Error(w, "failed to encode history", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// HandleTraceByID returns all stored entries referencing the given trace id,
|
|
// so agents don't have to rely on the trace id happening to substring-match
|
|
// inside a search result.
|
|
func (c *Collector) HandleTraceByID(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
traceID := r.PathValue("traceId")
|
|
if traceID == "" {
|
|
http.Error(w, "traceId is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
entries, err := c.SearchHistory(r.Context(), storage.SearchOptions{Query: traceID}, parseLimit(r, 1000))
|
|
if err != nil {
|
|
log.Printf("failed to fetch trace %s: %v", traceID, err)
|
|
http.Error(w, "failed to fetch trace", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(entries); err != nil {
|
|
log.Printf("failed to encode trace: %v", err)
|
|
http.Error(w, "failed to encode trace", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleIssues returns deduplicated error issues derived from ingested
|
|
// traces and logs, most recently seen first.
|
|
func (c *Collector) HandleIssues(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
list := c.ListIssues(parseLimit(r, 100))
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(list); err != nil {
|
|
log.Printf("failed to encode issues: %v", err)
|
|
http.Error(w, "failed to encode issues", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleIssueByID returns a single issue by id, including its samples.
|
|
func (c *Collector) HandleIssueByID(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
id := r.PathValue("id")
|
|
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)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleHealth returns the health status of the collector.
|
|
func (c *Collector) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := json.NewEncoder(w).Encode(map[string]string{"status": "ok"}); err != nil {
|
|
log.Printf("failed to encode health check: %v", err)
|
|
}
|
|
}
|