Add frontend error collector, CORS/ingest guards, and static doc routes (Phase 3)
- Add static/otel-rum.js: a framework-free browser script that captures
uncaught errors, unhandled promise rejections and failed resource loads,
translating them into OTLP log records POSTed to the existing /v1/logs
endpoint (source: frontend attribute) so they flow through the same
history/search/issue pipeline as backend telemetry.
- Add correlation-id support: traceHeaders() (manual) and same-origin
fetch auto-instrumentation inject a W3C traceparent header, so a
frontend error and the backend spans it triggered share a trace id and
can be found together via GET /api/traces/{traceId}.
- Add CORS handling (withCORS in cmd/server/main.go) on the OTLP HTTP
endpoints, required for any browser-origin POST.
- Add an optional shared ingest key (OTEL_INGEST_KEY, via '?key=' or
X-Otel-Ingest-Key) and a per-IP rate limit (20 rps/burst 50) on OTLP
HTTP ingestion, to scope/protect a public endpoint fed by a browser
script. Verified end-to-end (CORS preflight, cross-origin POST, ingest
key enforcement, rate limiting) against a running server.
- Serve static/otel-rum.js and agents_example.md as static HTTP routes so
both the script and the agent instructions doc are fetchable directly
from the base URL.
- Ignore the separately-cloned otel-ui/ frontend repo (has its own .git;
not meant to be nested into this repo's history).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0d274ff89c
commit
c20a0b77f3
10 files changed
+489
-17
No files matched your search
@@ -32,6 +32,9 @@ type Collector struct {
|
||||
subMutex sync.Mutex
|
||||
waitLimiter *ipRateLimiter
|
||||
issues *issues.Tracker
|
||||
|
||||
ingestLimiter *ipRateLimiter
|
||||
ingestKey string
|
||||
}
|
||||
|
||||
// New creates a new Collector instance.
|
||||
@@ -45,9 +48,22 @@ func New(broadcaster websocket.Broadcaster, historySize int, store storage.Store
|
||||
// Allow bursts of a few immediate waits, then steady-state ~1 req/sec per IP.
|
||||
waitLimiter: newIPRateLimiter(1, 5),
|
||||
issues: issues.NewTracker(),
|
||||
// Generous enough for normal exporter batching, but enough to stop a
|
||||
// runaway client (e.g. a frontend error handler that itself throws,
|
||||
// looping) from hammering the collector.
|
||||
ingestLimiter: newIPRateLimiter(20, 50),
|
||||
}
|
||||
}
|
||||
|
||||
// SetIngestKey configures an optional shared key that HTTP OTLP ingestion
|
||||
// (traces/metrics/logs) must present, via a '?key=' query parameter or an
|
||||
// 'X-Otel-Ingest-Key' header. An empty key (the default) disables the check.
|
||||
// This is meant to scope/rate-limit a public ingest endpoint (e.g. one fed
|
||||
// by a browser script), not to provide real authentication.
|
||||
func (c *Collector) SetIngestKey(key string) {
|
||||
c.ingestKey = key
|
||||
}
|
||||
|
||||
// ListIssues returns deduplicated error issues, most recently seen first.
|
||||
func (c *Collector) ListIssues(limit int) []issues.Issue {
|
||||
return c.issues.List(limit)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
@@ -318,3 +319,61 @@ func TestHandleIssuesAndByID(t *testing.T) {
|
||||
t.Fatalf("expected 404 for unknown issue id, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLogsIngestKey(t *testing.T) {
|
||||
coll := New(noopBroadcaster{}, 10, nil)
|
||||
coll.SetIngestKey("secret")
|
||||
|
||||
body := []byte(`{"resourceLogs":[]}`)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
header string
|
||||
wantStatus int
|
||||
}{
|
||||
{"no key", "/v1/logs", "", http.StatusUnauthorized},
|
||||
{"wrong key", "/v1/logs?key=wrong", "", http.StatusUnauthorized},
|
||||
{"correct key in query", "/v1/logs?key=secret", "", http.StatusOK},
|
||||
{"correct key in header", "/v1/logs", "secret", http.StatusOK},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, tc.url, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if tc.header != "" {
|
||||
req.Header.Set("X-Otel-Ingest-Key", tc.header)
|
||||
}
|
||||
// Shared IP across subtests; the ingest limiter's burst of 50 is
|
||||
// well above this test's request count.
|
||||
req.RemoteAddr = "198.51.100.1:1234"
|
||||
rec := httptest.NewRecorder()
|
||||
coll.HandleLogs(rec, req)
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Errorf("expected status %d, got %d (body=%s)", tc.wantStatus, rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLogsIngestRateLimited(t *testing.T) {
|
||||
coll := New(noopBroadcaster{}, 10, nil)
|
||||
body := []byte(`{"resourceLogs":[]}`)
|
||||
|
||||
var lastCode int
|
||||
for i := 0; i < 80; i++ {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/logs", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.RemoteAddr = "198.51.100.2:1234"
|
||||
rec := httptest.NewRecorder()
|
||||
coll.HandleLogs(rec, req)
|
||||
lastCode = rec.Code
|
||||
if lastCode == http.StatusTooManyRequests {
|
||||
break
|
||||
}
|
||||
}
|
||||
if lastCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected ingest rate limiting to eventually return 429, last code was %d", lastCode)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,28 @@ import (
|
||||
"go.opentelemetry.io/collector/pdata/ptrace"
|
||||
)
|
||||
|
||||
// checkIngest applies the shared ingest rate limit and, if configured, the
|
||||
// ingest key check. It writes an error response and returns false when the
|
||||
// request should be rejected.
|
||||
func (c *Collector) checkIngest(w http.ResponseWriter, r *http.Request) bool {
|
||||
if !c.ingestLimiter.Allow(clientIP(r)) {
|
||||
http.Error(w, "too many requests, slow down", http.StatusTooManyRequests)
|
||||
return false
|
||||
}
|
||||
if c.ingestKey == "" {
|
||||
return true
|
||||
}
|
||||
key := r.Header.Get("X-Otel-Ingest-Key")
|
||||
if key == "" {
|
||||
key = r.URL.Query().Get("key")
|
||||
}
|
||||
if key != c.ingestKey {
|
||||
http.Error(w, "invalid or missing ingest key", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 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"))
|
||||
@@ -26,6 +48,9 @@ func (c *Collector) HandleTraces(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !c.checkIngest(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
@@ -71,6 +96,9 @@ func (c *Collector) HandleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !c.checkIngest(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
@@ -112,6 +140,9 @@ func (c *Collector) HandleLogs(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !c.checkIngest(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user