better
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
./server
|
||||||
|
.DS_Store
|
||||||
+4
-2
@@ -1,4 +1,6 @@
|
|||||||
# UI build stage
|
# UI build stage
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
FROM node:alpine AS ui-builder
|
FROM node:alpine AS ui-builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -8,7 +10,7 @@ COPY otel-ui/ .
|
|||||||
RUN npm install && npm run build
|
RUN npm install && npm run build
|
||||||
|
|
||||||
# Go build stage
|
# Go build stage
|
||||||
FROM golang:1.25-alpine AS go-builder
|
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS go-builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -21,7 +23,7 @@ COPY internal/ ./internal
|
|||||||
# Copy built UI dist
|
# Copy built UI dist
|
||||||
COPY --from=ui-builder /app/dist ./otel-ui/dist
|
COPY --from=ui-builder /app/dist ./otel-ui/dist
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o /go-otel ./cmd/server
|
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo -o /go-otel ./cmd/server
|
||||||
|
|
||||||
# Final stage
|
# Final stage
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ OTLP Client → HTTP/gRPC Server → Collector → WebSocket Broadcast → Conne
|
|||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
```sh
|
```sh
|
||||||
git clone https://git.tornberg.me/go-otel.git
|
git clone https://git.k6n.net/go-otel.git
|
||||||
cd go-otel
|
cd go-otel
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# AI Agent Debugging with OTEL Collector
|
||||||
|
|
||||||
|
This document provides instructions for AI agents on how to utilize the OTEL Collector's debugging features to search and fetch telemetry data (traces, logs, and metrics).
|
||||||
|
|
||||||
|
## Base Configuration
|
||||||
|
- **Base URL**: `https://otel.k6n.net`
|
||||||
|
- **Endpoints**:
|
||||||
|
- `GET /api/history/search?q={query}&limit={limit}`: Search all historical telemetry data.
|
||||||
|
- **Default Limit**: 50 (max 100 recommended for context).
|
||||||
|
- **Ordering**: Latest entries first.
|
||||||
|
- **Match Scope**: Case-insensitive substring match on raw JSON (covers service name, span body, attributes, etc.).
|
||||||
|
- `GET /api/history/wait?q={query}&timeout={seconds}`: **Wait** for a specific signal to arrive.
|
||||||
|
- **Blocking**: This call blocks until a match is found or the timeout (default 30s) is reached.
|
||||||
|
- **Ideal for Automation**: Use this after triggering an action to wait for the exact result.
|
||||||
|
- `GET /api/history`: Fetch the recent in-memory history of all telemetry.
|
||||||
|
- `GET /api/history/traces`: Fetch recent traces.
|
||||||
|
- `GET /api/history/logs`: Fetch recent logs.
|
||||||
|
- `GET /api/history/metrics`: Fetch recent metrics.
|
||||||
|
|
||||||
|
## Agent Instructions
|
||||||
|
|
||||||
|
### 1. Searching for Context
|
||||||
|
When debugging an issue, start by searching for relevant keywords in the logs and traces. This helps locate specific errors or trace IDs associated with a failure. Since results are **latest first**, you will always see the most recent events related to your search.
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
# Search for recent "ERROR" occurrences (default limit 50, latest first)
|
||||||
|
curl "https://otel.k6n.net/api/history/search?q=error"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Waiting for a Signal
|
||||||
|
Use the `wait` endpoint to synchronize your debugging flow. This is perfect for verifying that a specific event occurred after you triggered an action.
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
# Wait up to 60s for a specific traceId to show up in the collector
|
||||||
|
curl "https://otel.k6n.net/api/history/wait?q=5682138e4a9b4629852899d45e542456&timeout=60"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Identifying Trace Chains
|
||||||
|
If you find a log entry with a `traceId`, use the search endpoint to find all spans and logs associated with that specific trace to understand the full request flow.
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
# Search for all activity related to a specific trace
|
||||||
|
curl "https://otel.k6n.net/api/history/search?q=5682138e4a9b4629852899d45e542456&limit=100"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Fetching Recent Telemetry
|
||||||
|
To see the most recent activity without specific keywords, use the search endpoint with an empty query.
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
curl "https://otel.k6n.net/api/history/search?limit=20"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Data Format
|
||||||
|
The returned data is a JSON array of `HistoryEntry` objects:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "logs",
|
||||||
|
"timestamp": 1712132978000000000,
|
||||||
|
"data": { ... OTEL Log Records ... }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "traces",
|
||||||
|
"timestamp": 1712132979000000000,
|
||||||
|
"data": { ... OTEL Resource Spans ... }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices for AI Agents
|
||||||
|
- **Iterative Search**: Start with a broad search (e.g., service name) and narrow down using specific attributes or error messages found in the results.
|
||||||
|
- **Limit results**: Always use the `limit` parameter to manage context window size (default is 100).
|
||||||
|
- **Correlate Signals**: Look for timestamps that coincide across logs and metrics to identify resource constraints or timing issues.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
docker build -t matst80/otel-debug:latest .
|
||||||
|
docker push matst80/otel-debug:latest
|
||||||
|
|
||||||
|
kubectl rollout restart deployment otel-debug-receiver -n monitoring
|
||||||
|
|
||||||
|
kubectl logs -f deployment/otel-debug-receiver -n monitoring
|
||||||
+126
-2
@@ -7,9 +7,12 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.tornberg.me/go-otel/internal/collector"
|
"git.tornberg.me/go-otel/internal/collector"
|
||||||
|
"git.tornberg.me/go-otel/internal/storage"
|
||||||
"git.tornberg.me/go-otel/internal/websocket"
|
"git.tornberg.me/go-otel/internal/websocket"
|
||||||
"go.opentelemetry.io/collector/pdata/plog"
|
"go.opentelemetry.io/collector/pdata/plog"
|
||||||
"go.opentelemetry.io/collector/pdata/pmetric"
|
"go.opentelemetry.io/collector/pdata/pmetric"
|
||||||
@@ -18,9 +21,43 @@ import (
|
|||||||
metricsgrpc "go.opentelemetry.io/proto/otlp/collector/metrics/v1"
|
metricsgrpc "go.opentelemetry.io/proto/otlp/collector/metrics/v1"
|
||||||
tracegrpc "go.opentelemetry.io/proto/otlp/collector/trace/v1"
|
tracegrpc "go.opentelemetry.io/proto/otlp/collector/trace/v1"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/grpc/stats"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// gRPC Logging Interceptor
|
||||||
|
func loggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
||||||
|
resp, err := handler(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
st, _ := status.FromError(err)
|
||||||
|
log.Printf("gRPC ERROR [%s] code=%s message=%s", info.FullMethod, st.Code(), st.Message())
|
||||||
|
} else {
|
||||||
|
log.Printf("gRPC SUCCESS [%s]", info.FullMethod)
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// gRPC Connection Stats Handler
|
||||||
|
type connStatsHandler struct {
|
||||||
|
stats.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *connStatsHandler) TagConn(ctx context.Context, info *stats.ConnTagInfo) context.Context {
|
||||||
|
log.Printf("gRPC CONN [OPEN] from=%s", info.RemoteAddr)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *connStatsHandler) HandleConn(ctx context.Context, s stats.ConnStats) {
|
||||||
|
switch s.(type) {
|
||||||
|
case *stats.ConnEnd:
|
||||||
|
log.Printf("gRPC CONN [CLOSED]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *connStatsHandler) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context { return ctx }
|
||||||
|
func (h *connStatsHandler) HandleRPC(ctx context.Context, s stats.RPCStats) {}
|
||||||
|
|
||||||
type traceService struct {
|
type traceService struct {
|
||||||
tracegrpc.UnimplementedTraceServiceServer
|
tracegrpc.UnimplementedTraceServiceServer
|
||||||
collector *collector.Collector
|
collector *collector.Collector
|
||||||
@@ -115,7 +152,8 @@ func main() {
|
|||||||
|
|
||||||
// Configure history size (default 100, can be made configurable)
|
// Configure history size (default 100, can be made configurable)
|
||||||
historySize := 100
|
historySize := 100
|
||||||
coll := collector.New(nil, historySize) // Temporary nil for wsServer
|
store := storage.NewFileStore("otel-agent.log")
|
||||||
|
coll := collector.New(nil, historySize, store) // Temporary nil for wsServer
|
||||||
wsServer := websocket.New(coll)
|
wsServer := websocket.New(coll)
|
||||||
// Update collector with wsServer
|
// Update collector with wsServer
|
||||||
coll.SetBroadcaster(wsServer)
|
coll.SetBroadcaster(wsServer)
|
||||||
@@ -127,7 +165,10 @@ func main() {
|
|||||||
log.Fatal("failed to listen for gRPC", err)
|
log.Fatal("failed to listen for gRPC", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s := grpc.NewServer()
|
s := grpc.NewServer(
|
||||||
|
grpc.UnaryInterceptor(loggingInterceptor),
|
||||||
|
grpc.StatsHandler(&connStatsHandler{}),
|
||||||
|
)
|
||||||
tracegrpc.RegisterTraceServiceServer(s, &traceService{collector: coll})
|
tracegrpc.RegisterTraceServiceServer(s, &traceService{collector: coll})
|
||||||
metricsgrpc.RegisterMetricsServiceServer(s, &metricsService{collector: coll})
|
metricsgrpc.RegisterMetricsServiceServer(s, &metricsService{collector: coll})
|
||||||
logsgrpc.RegisterLogsServiceServer(s, &logsService{collector: coll})
|
logsgrpc.RegisterLogsServiceServer(s, &logsService{collector: coll})
|
||||||
@@ -148,6 +189,8 @@ func main() {
|
|||||||
http.HandleFunc("/api/history/traces", handleHistoryByType(coll, "traces"))
|
http.HandleFunc("/api/history/traces", handleHistoryByType(coll, "traces"))
|
||||||
http.HandleFunc("/api/history/metrics", handleHistoryByType(coll, "metrics"))
|
http.HandleFunc("/api/history/metrics", handleHistoryByType(coll, "metrics"))
|
||||||
http.HandleFunc("/api/history/logs", handleHistoryByType(coll, "logs"))
|
http.HandleFunc("/api/history/logs", handleHistoryByType(coll, "logs"))
|
||||||
|
http.HandleFunc("/api/history/search", handleSearch(coll))
|
||||||
|
http.HandleFunc("/api/history/wait", handleWait(coll))
|
||||||
|
|
||||||
// WebSocket server for visualization
|
// WebSocket server for visualization
|
||||||
http.HandleFunc("/ws", wsServer.HandleConnections)
|
http.HandleFunc("/ws", wsServer.HandleConnections)
|
||||||
@@ -320,6 +363,87 @@ func handleHistory(coll *collector.Collector) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleSearch(coll *collector.Collector) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := r.URL.Query().Get("q")
|
||||||
|
limitStr := r.URL.Query().Get("limit")
|
||||||
|
limit := 100
|
||||||
|
if limitStr != "" {
|
||||||
|
if l, err := strconv.Atoi(limitStr); err == nil {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := coll.SearchHistory(r.Context(), query, limit)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleWait(coll *collector.Collector) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := r.URL.Query().Get("q")
|
||||||
|
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 := coll.SearchHistory(ctx, query, 1)
|
||||||
|
if err == nil && len(matches) > 0 {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(matches[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, subscribe and wait
|
||||||
|
lowerQuery := strings.ToLower(query)
|
||||||
|
ch, cleanup := coll.Subscribe(func(entry websocket.HistoryEntry) bool {
|
||||||
|
if query == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(entry.Data)
|
||||||
|
return strings.Contains(strings.ToLower(string(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleHistoryByType(coll *collector.Collector, dataType string) http.HandlerFunc {
|
func handleHistoryByType(coll *collector.Collector, dataType string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ require (
|
|||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
|
github.com/natefinch/lumberjack v2.0.0+incompatible // indirect
|
||||||
go.opentelemetry.io/collector/featuregate v1.45.0 // indirect
|
go.opentelemetry.io/collector/featuregate v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
|
|||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM=
|
||||||
|
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-otel/internal/storage"
|
||||||
"git.tornberg.me/go-otel/internal/websocket"
|
"git.tornberg.me/go-otel/internal/websocket"
|
||||||
|
|
||||||
"go.opentelemetry.io/collector/consumer"
|
"go.opentelemetry.io/collector/consumer"
|
||||||
@@ -21,13 +23,18 @@ type Collector struct {
|
|||||||
historySize int
|
historySize int
|
||||||
history *ring.Ring
|
history *ring.Ring
|
||||||
historyMutex sync.RWMutex
|
historyMutex sync.RWMutex
|
||||||
|
store storage.Store
|
||||||
|
subscribers map[chan websocket.HistoryEntry]func(websocket.HistoryEntry) bool
|
||||||
|
subMutex sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(broadcaster websocket.Broadcaster, historySize int) *Collector {
|
func New(broadcaster websocket.Broadcaster, historySize int, store storage.Store) *Collector {
|
||||||
return &Collector{
|
return &Collector{
|
||||||
broadcaster: broadcaster,
|
broadcaster: broadcaster,
|
||||||
historySize: historySize,
|
historySize: historySize,
|
||||||
history: ring.New(historySize),
|
history: ring.New(historySize),
|
||||||
|
store: store,
|
||||||
|
subscribers: make(map[chan websocket.HistoryEntry]func(websocket.HistoryEntry) bool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,15 +43,59 @@ func (c *Collector) SetBroadcaster(broadcaster websocket.Broadcaster) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Collector) addToHistory(dataType websocket.DataType, data interface{}) {
|
func (c *Collector) addToHistory(dataType websocket.DataType, data interface{}) {
|
||||||
c.historyMutex.Lock()
|
entry := websocket.HistoryEntry{
|
||||||
defer c.historyMutex.Unlock()
|
|
||||||
|
|
||||||
c.history.Value = websocket.HistoryEntry{
|
|
||||||
Type: string(dataType),
|
Type: string(dataType),
|
||||||
Timestamp: time.Now().UnixNano(),
|
Timestamp: time.Now().UnixNano(),
|
||||||
Data: data,
|
Data: data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.historyMutex.Lock()
|
||||||
|
c.history.Value = entry
|
||||||
c.history = c.history.Next()
|
c.history = c.history.Next()
|
||||||
|
c.historyMutex.Unlock()
|
||||||
|
|
||||||
|
if c.store != nil {
|
||||||
|
// Asynchronously append to store to avoid blocking ingestion
|
||||||
|
go func() {
|
||||||
|
if err := c.store.Append(context.Background(), entry); err != nil {
|
||||||
|
log.Printf("failed to append to store: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.notifySubscribers(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) notifySubscribers(entry websocket.HistoryEntry) {
|
||||||
|
c.subMutex.Lock()
|
||||||
|
defer c.subMutex.Unlock()
|
||||||
|
|
||||||
|
for ch, filter := range c.subscribers {
|
||||||
|
if filter(entry) {
|
||||||
|
select {
|
||||||
|
case ch <- entry:
|
||||||
|
// Successfully sent
|
||||||
|
default:
|
||||||
|
// Channel blocked, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) Subscribe(filter func(websocket.HistoryEntry) bool) (chan websocket.HistoryEntry, func()) {
|
||||||
|
ch := make(chan websocket.HistoryEntry, 1)
|
||||||
|
c.subMutex.Lock()
|
||||||
|
c.subscribers[ch] = filter
|
||||||
|
c.subMutex.Unlock()
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
c.subMutex.Lock()
|
||||||
|
delete(c.subscribers, ch)
|
||||||
|
c.subMutex.Unlock()
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ch, cleanup
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Collector) GetHistory() []websocket.HistoryEntry {
|
func (c *Collector) GetHistory() []websocket.HistoryEntry {
|
||||||
@@ -60,6 +111,47 @@ func (c *Collector) GetHistory() []websocket.HistoryEntry {
|
|||||||
return history
|
return history
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Collector) SearchHistory(ctx context.Context, query string, limit int) ([]websocket.HistoryEntry, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.store != nil {
|
||||||
|
return c.store.Search(ctx, query, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to in-memory if no store
|
||||||
|
history := c.GetHistory()
|
||||||
|
var filtered []websocket.HistoryEntry
|
||||||
|
// Lowercase query for case-insensitive search
|
||||||
|
lowerQuery := strings.ToLower(query)
|
||||||
|
|
||||||
|
for _, entry := range history {
|
||||||
|
if query == "" {
|
||||||
|
filtered = append(filtered, entry)
|
||||||
|
} else {
|
||||||
|
// Basic string filter for in-memory
|
||||||
|
data, _ := json.Marshal(entry.Data)
|
||||||
|
if strings.Contains(strings.ToLower(string(data)), lowerQuery) {
|
||||||
|
filtered = append(filtered, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep it simple and just filter all then limit, since historySize is usually small
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect limit and return latest messages first
|
||||||
|
if len(filtered) > limit {
|
||||||
|
filtered = filtered[len(filtered)-limit:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse to match Store's Search behavior (latest first)
|
||||||
|
for i, j := 0, len(filtered)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
filtered[i], filtered[j] = filtered[j], filtered[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Collector) ConsumeTraces(ctx context.Context, td ptrace.Traces) error {
|
func (c *Collector) ConsumeTraces(ctx context.Context, td ptrace.Traces) error {
|
||||||
marshaler := &ptrace.JSONMarshaler{}
|
marshaler := &ptrace.JSONMarshaler{}
|
||||||
buf, err := marshaler.MarshalTraces(td)
|
buf, err := marshaler.MarshalTraces(td)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-otel/internal/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockStore struct {
|
||||||
|
entries []websocket.HistoryEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockStore) Append(ctx context.Context, entry websocket.HistoryEntry) error {
|
||||||
|
m.entries = append(m.entries, entry)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockStore) GetHistory(ctx context.Context, limit int) ([]websocket.HistoryEntry, error) {
|
||||||
|
return m.entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockStore) Search(ctx context.Context, query string, limit int) ([]websocket.HistoryEntry, error) {
|
||||||
|
return m.entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectorWithStore(t *testing.T) {
|
||||||
|
store := &mockStore{}
|
||||||
|
coll := New(nil, 10, store)
|
||||||
|
|
||||||
|
coll.addToHistory(websocket.DataTypeLogs, map[string]interface{}{"msg": "test"})
|
||||||
|
|
||||||
|
// Give it a moment since it's asynchronous
|
||||||
|
timeout := time.After(100 * time.Millisecond)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-timeout:
|
||||||
|
t.Fatal("timed out waiting for entry to be appended to store")
|
||||||
|
default:
|
||||||
|
if len(store.entries) > 0 {
|
||||||
|
goto OK
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OK:
|
||||||
|
if store.entries[0].Type != "logs" {
|
||||||
|
t.Errorf("expected type 'logs', got %s", store.entries[0].Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-otel/internal/websocket"
|
||||||
|
"github.com/natefinch/lumberjack"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileStore struct {
|
||||||
|
logger *lumberjack.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileStore(filename string) *FileStore {
|
||||||
|
return &FileStore{
|
||||||
|
logger: &lumberjack.Logger{
|
||||||
|
Filename: filename,
|
||||||
|
MaxSize: 100, // megabytes
|
||||||
|
MaxBackups: 3,
|
||||||
|
MaxAge: 28, // days
|
||||||
|
Compress: true, // disabled by default
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileStore) Append(ctx context.Context, entry websocket.HistoryEntry) error {
|
||||||
|
data, err := json.Marshal(entry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = fs.logger.Write(append(data, '\n'))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileStore) GetHistory(ctx context.Context, limit int) ([]websocket.HistoryEntry, error) {
|
||||||
|
return fs.Search(ctx, "", limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileStore) Search(ctx context.Context, query string, limit int) ([]websocket.HistoryEntry, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50 // Default to a manageable number
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(fs.logger.Filename)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil // No logs yet
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var matchingLines []string
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
// Lowercase query for case-insensitive search
|
||||||
|
lowerQuery := strings.ToLower(query)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if query == "" || strings.Contains(strings.ToLower(line), lowerQuery) {
|
||||||
|
matchingLines = append(matchingLines, line)
|
||||||
|
// Only keep the most recent 'limit' matches to save memory
|
||||||
|
if len(matchingLines) > limit {
|
||||||
|
matchingLines = matchingLines[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []websocket.HistoryEntry
|
||||||
|
// Reverse to get latest first (since matchingLines contains them in chronological order)
|
||||||
|
for i := len(matchingLines) - 1; i >= 0; i-- {
|
||||||
|
var entry websocket.HistoryEntry
|
||||||
|
if err := json.Unmarshal([]byte(matchingLines[i]), &entry); err == nil {
|
||||||
|
results = append(results, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, scanner.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-otel/internal/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFileStore(t *testing.T) {
|
||||||
|
filename := "test-otel.log"
|
||||||
|
defer os.Remove(filename)
|
||||||
|
|
||||||
|
fs := NewFileStore(filename)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
entry1 := websocket.HistoryEntry{
|
||||||
|
Type: "logs",
|
||||||
|
Timestamp: 100,
|
||||||
|
Data: map[string]interface{}{"msg": "hello world"},
|
||||||
|
}
|
||||||
|
entry2 := websocket.HistoryEntry{
|
||||||
|
Type: "traces",
|
||||||
|
Timestamp: 200,
|
||||||
|
Data: map[string]interface{}{"msg": "trace event"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fs.Append(ctx, entry1); err != nil {
|
||||||
|
t.Fatalf("failed to append entry1: %v", err)
|
||||||
|
}
|
||||||
|
if err := fs.Append(ctx, entry2); err != nil {
|
||||||
|
t.Fatalf("failed to append entry2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetHistory (limit 1)
|
||||||
|
history, err := fs.GetHistory(ctx, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 1 {
|
||||||
|
t.Errorf("expected 1 entry, got %d", len(history))
|
||||||
|
}
|
||||||
|
// Should be latest entry
|
||||||
|
if history[0].Type != "traces" {
|
||||||
|
t.Errorf("expected latest entry type 'traces', got %s", history[0].Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Search
|
||||||
|
results, err := fs.Search(ctx, "hello", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Errorf("expected 1 result for search 'hello', got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check content of result
|
||||||
|
data, _ := json.Marshal(results[0].Data)
|
||||||
|
if string(data) != `{"msg":"hello world"}` {
|
||||||
|
t.Errorf("unexpected data content: %s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"git.tornberg.me/go-otel/internal/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
Append(ctx context.Context, entry websocket.HistoryEntry) error
|
||||||
|
GetHistory(ctx context.Context, limit int) ([]websocket.HistoryEntry, error)
|
||||||
|
Search(ctx context.Context, query string, limit int) ([]websocket.HistoryEntry, error)
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ spec:
|
|||||||
name: http
|
name: http
|
||||||
- containerPort: 4317
|
- containerPort: 4317
|
||||||
name: grpc
|
name: grpc
|
||||||
|
env:
|
||||||
|
- name: GRPC_GO_LOG_SEVERITY_LEVEL
|
||||||
|
value: "info"
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "128Mi"
|
memory: "128Mi"
|
||||||
|
|||||||
Reference in New Issue
Block a user