From 6e2265f5aebece983511f30d065b950b69cfb608 Mon Sep 17 00:00:00 2001 From: skota-hash Date: Tue, 28 Apr 2026 01:27:51 -0400 Subject: [PATCH 01/12] feat(ingest): added schema v2 ingest envelope at /v1/events Replaced the legacy v1 single-event ingest handler with a schema-2.0 validation-only handler mounted at /v1/events. The new path accepts single JSON events, NDJSON batches, and optional gzip encoding, enforces the 1 MB body limit and 256-event batch limit, validates raw decoded event shapes against the embedded v2.0 schema, and returns the structured ingest envelope with accepted, duplicate, rejected, and deprecations fields. Embed the v2 schema into pkg/event/v2 so the ingest binary no longer depends on the docs tree at runtime. Export reusable CompileSchema and ValidateAny helpers and keep the existing Validate API as a compatibility wrapper. Extend ingest metrics with new rejection reasons and the waylog_ingest_batch_size histogram while preserving existing validation and sampling labels. Do not increment waylog_events_accepted_total from this path because Slice 1 only validates and discards; durable WAL persistence and graph merge remain deferred to Slice 2/3. Teach Go and TypeScript SDK transports to count Deprecation/Sunset response headers independently from envelope-body parsing, so empty 2xx responses still surface deprecation signals. Expose the count in SDK stats. Update tests for the new v2 handler, raw-shape schema validation regressions, gzip/body/batch limits, auth/method handling, metrics, SDK header counting, and integration helpers after removing Server.Events. Document the temporary validate-and-discard semantics in README and CHANGELOG. --- cmd/ingest/main.go | 8 +- internal/ingest/handler.go | 150 +------ internal/ingest/handler_test.go | 165 +------- internal/ingest/v2/envelope.go | 47 +++ internal/ingest/v2/handler.go | 131 ++++++ internal/ingest/v2/handler_test.go | 391 ++++++++++++++++++ internal/ingest/v2/parse.go | 111 +++++ .../ingest/v2/testdata/malformed-line.ndjson | 2 + .../ingest/v2/testdata/mixed-validity.ndjson | 3 + .../ingest/v2/testdata/oversize-batch.ndjson | 277 +++++++++++++ internal/metrics/metrics.go | 21 +- .../waylog-ts/src/__tests__/transport.test.ts | 12 + packages/waylog-ts/src/logger.ts | 3 + packages/waylog-ts/src/transport.ts | 11 + packages/waylog-ts/src/types.ts | 1 + pkg/event/v2/bridge_test.go | 6 +- pkg/event/v2/embed.go | 25 ++ pkg/event/v2/embed_test.go | 53 +++ pkg/event/v2/event.go | 34 +- pkg/event/v2/v2.0.schema.json | 98 +++++ pkg/transport/http/batch.go | 1 + pkg/transport/http/client.go | 21 +- pkg/transport/http/client_test.go | 37 ++ pkg/waylog/v2/waylog.go | 61 +-- tests/integration/helpers_test.go | 48 ++- tests/integration/otlp_test.go | 2 +- 26 files changed, 1358 insertions(+), 361 deletions(-) create mode 100644 internal/ingest/v2/envelope.go create mode 100644 internal/ingest/v2/handler.go create mode 100644 internal/ingest/v2/handler_test.go create mode 100644 internal/ingest/v2/parse.go create mode 100644 internal/ingest/v2/testdata/malformed-line.ndjson create mode 100644 internal/ingest/v2/testdata/mixed-validity.ndjson create mode 100644 internal/ingest/v2/testdata/oversize-batch.ndjson create mode 100644 pkg/event/v2/embed.go create mode 100644 pkg/event/v2/embed_test.go create mode 100644 pkg/event/v2/v2.0.schema.json diff --git a/cmd/ingest/main.go b/cmd/ingest/main.go index 7524172..972da4e 100644 --- a/cmd/ingest/main.go +++ b/cmd/ingest/main.go @@ -25,6 +25,7 @@ import ( "github.com/sssmaran/WaylogCLI/internal/graph/core" graphstore "github.com/sssmaran/WaylogCLI/internal/graph/store" "github.com/sssmaran/WaylogCLI/internal/ingest" + ingestv2 "github.com/sssmaran/WaylogCLI/internal/ingest/v2" "github.com/sssmaran/WaylogCLI/internal/mcp/stdio" "github.com/sssmaran/WaylogCLI/internal/metrics" otelhttp "github.com/sssmaran/WaylogCLI/internal/otel" @@ -302,7 +303,12 @@ func main() { mux.Handle("/metrics", m.Handler()) // Write endpoints. - mux.Handle("/v1/events", writeAuth(http.HandlerFunc(ingestServer.Events))) + eventsV2, err := ingestv2.New(m) + if err != nil { + slog.Error("initialize v2 ingest handler", "err", err) + os.Exit(1) + } + mux.Handle("/v1/events", writeAuth(http.HandlerFunc(eventsV2.Events))) mux.Handle("/v1/events/validate", writeAuth(http.HandlerFunc(ingestServer.Validate))) // OTLP/HTTP traces — routed through a dedicated pipeline that reuses the diff --git a/internal/ingest/handler.go b/internal/ingest/handler.go index b1c8768..7d86f79 100644 --- a/internal/ingest/handler.go +++ b/internal/ingest/handler.go @@ -366,155 +366,9 @@ func (s *Server) SetReplayResult(err error) { } } -// Events handles event ingestion requests. -func (s *Server) Events(w http.ResponseWriter, r *http.Request) { - start := time.Now() - if s.metrics != nil { - s.metrics.InFlightRequests.Inc() - defer s.metrics.InFlightRequests.Dec() - defer func() { s.metrics.IngestLatency.Observe(time.Since(start).Seconds()) }() - } - - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - r.Body = http.MaxBytesReader(w, r.Body, s.maxBodyBytes) - - var ev event.WideEvent - dec := json.NewDecoder(r.Body) - dec.DisallowUnknownFields() - - if err := dec.Decode(&ev); err != nil { - var maxErr *http.MaxBytesError - if errors.As(err, &maxErr) { - if s.metrics != nil { - s.metrics.EventsRejected.WithLabelValues("validation").Inc() - } - http.Error(w, "request body too large", http.StatusRequestEntityTooLarge) - return - } - slog.Warn("json decode failed", "err", err) - if s.metrics != nil { - s.metrics.EventsRejected.WithLabelValues("validation").Inc() - } - http.Error(w, "invalid json", http.StatusBadRequest) - return - } - - // Ensure server-side timestamp sanity - if ev.Timestamp.After(time.Now().Add(5 * time.Minute)) { - if s.metrics != nil { - s.metrics.EventsRejected.WithLabelValues("validation").Inc() - } - http.Error(w, "timestamp too far in future", http.StatusBadRequest) - return - } - - if err := ev.Validate(); err != nil { - slog.Warn("event validation failed", "err", err) - if s.metrics != nil { - s.metrics.EventsRejected.WithLabelValues("validation").Inc() - } - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - sampled := s.sampler.ShouldKeep(ev) - - // Write-ahead: the eventlog is the durable source of truth. If it's - // configured and the write fails, reject the event so the client retries. - // Nothing enters the graph without being logged first. - if s.EventLog != nil { - if err := s.EventLog.Write(&ev, sampled); err != nil { - slog.Error("eventlog write failed", "err", err) - if s.metrics != nil { - s.metrics.EventlogFails.Inc() - } - http.Error(w, "event log unavailable", http.StatusServiceUnavailable) - return - } - } - - // Windowed unsampled counters — incremented after successful WAL write - // so rejected events (WAL failure → 503) are never counted. - s.counters.Inc(!ev.Outcome.Success) - - // Enqueue ALL accepted events to cold store (before sampling gate) - // so /v1/events/search returns complete results regardless of sampling. - if s.coldWriter != nil { - s.coldWriter.Enqueue(ev) - } - - // Auto-extract deployment from event (post-WAL, pre-sampling gate). - // Uses detached context: event is already durable in WAL, client disconnect - // must not abort the upsert. - if ev.System.DeploymentID != "" && s.coldStore != nil { - upsertCtx, upsertCancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - err := s.coldStore.UpsertDeployment(upsertCtx, coldstore.Deployment{ - ID: ev.System.DeploymentID, - Service: ev.System.Service, - Version: ev.System.Version, - Env: ev.System.Env, - FirstSeen: ev.Timestamp, - LastSeen: ev.Timestamp, - }) - upsertCancel() - if err != nil { - if !errors.Is(err, coldstore.ErrEnvConflict) && s.metrics != nil { - s.metrics.DeployUpsertErrors.Inc() - } - slog.Warn("deployment auto-extract failed", - "deployment_id", ev.System.DeploymentID, - "err", err, - ) - } else if s.metrics != nil { - s.metrics.DeployUpsertsTotal.Inc() - } - } - - if !sampled { - if s.metrics != nil { - s.metrics.EventsRejected.WithLabelValues("sampling").Inc() - } - w.WriteHeader(http.StatusAccepted) - return - } - - slog.Info("event accepted", - "trace_id", ev.Request.TraceID, - "status_code", ev.Outcome.StatusCode, - "success", ev.Outcome.Success, - "error_code", errorCode(&ev), - ) - - // Build graph + trace-store records from event and merge into derived views. - mergeStart := time.Now() - result := s.builder.BuildResult(ev) - if s.store != nil { - s.store.Merge(result.Graph) - } - if s.traceStore != nil && result.Span != nil { - traceStart := time.Now() - s.traceStore.Upsert(ev.Request.TraceID, core.ID("request", ev.Request.TraceID), result.Span) - if s.metrics != nil { - s.metrics.TraceUpsertDuration.Observe(time.Since(traceStart).Seconds()) - } - } - if s.sseHub != nil { - s.sseHub.MarkDirty(TopicOverview, TopicRoutes, TopicTimeseries) - } - if s.metrics != nil { - s.metrics.MergeLatency.Observe(time.Since(mergeStart).Seconds()) - s.metrics.EventsAccepted.Inc() - } - - s.accepted.Add(1) - w.WriteHeader(http.StatusAccepted) -} - // Validate handles POST /v1/events/validate — dry-run validation without ingestion. +// TODO(phase2-slice2): rewrite this endpoint for the schema-2.0 envelope once +// WAL-backed v2 ingest is in place. It remains v1-compatible for now. func (s *Server) Validate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) diff --git a/internal/ingest/handler_test.go b/internal/ingest/handler_test.go index c85928f..fc12cbf 100644 --- a/internal/ingest/handler_test.go +++ b/internal/ingest/handler_test.go @@ -566,31 +566,6 @@ func TestReadEndpoints_NoStore(t *testing.T) { }) } -func TestEvents_BodyTooLarge(t *testing.T) { - srv := NewServer(ServerConfig{ - Store: graphstore.NewStore(), - MaxBodyBytes: 64, - }) - - largeJSON := `{"schema_version":"1.0","event_name":"test.request","padding":"` + strings.Repeat("a", 100) + `"}` - req := httptest.NewRequest(http.MethodPost, "/v1/events", strings.NewReader(largeJSON)) - w := httptest.NewRecorder() - srv.Events(w, req) - - if w.Code != http.StatusRequestEntityTooLarge { - t.Errorf("expected 413, got %d", w.Code) - } -} - -func TestEvents_DefaultMaxBody(t *testing.T) { - srv := NewServer(ServerConfig{ - Store: graphstore.NewStore(), - }) - if srv.maxBodyBytes != 1<<20 { - t.Errorf("expected default 1MB, got %d", srv.maxBodyBytes) - } -} - func TestValidate_ValidEvent(t *testing.T) { srv := NewServer(ServerConfig{Store: graphstore.NewStore()}) body := `{"schema_version":"1.0","event_name":"test.request","timestamp":"2026-02-17T10:00:00Z","user":{"id":"u1"},"request":{"trace_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"},"system":{"service":"test","env":"prod"},"outcome":{"success":true,"status_code":200,"kind":"http"},"metrics":{"latency_ms":10}}` @@ -905,8 +880,8 @@ func TestOverview_ErrorRateFromPresamplingCounters(t *testing.T) { Sampler: keepAllSampler(), }) - // Send 4 events through the handler: 3 success + 1 error. - makeBody := func(traceID string, success bool, code int, errCode string) string { + // Seed 4 events into the graph and pre-sampling counters: 3 success + 1 error. + makeEvent := func(traceID string, success bool, code int, errCode string) event.WideEvent { ev := testutil.MakeEvent( testutil.WithTraceID(traceID), testutil.WithService("svc"), @@ -917,21 +892,20 @@ func TestOverview_ErrorRateFromPresamplingCounters(t *testing.T) { ev.Error = &event.ErrorContext{Code: errCode, Message: "fail"} ev.EventName = "svc.error" } - b, _ := json.Marshal(ev) - return string(b) + return ev } - for _, body := range []string{ - makeBody("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1", true, 200, ""), - makeBody("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2", true, 200, ""), - makeBody("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa3", true, 200, ""), - makeBody("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa4", false, 500, "ERR_X"), + for _, ev := range []event.WideEvent{ + makeEvent("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1", true, 200, ""), + makeEvent("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2", true, 200, ""), + makeEvent("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa3", true, 200, ""), + makeEvent("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa4", false, 500, "ERR_X"), } { - req := httptest.NewRequest(http.MethodPost, "/v1/events", strings.NewReader(body)) - w := httptest.NewRecorder() - srv.Events(w, req) - if w.Code != http.StatusAccepted { - t.Fatalf("expected 202, got %d: %s", w.Code, w.Body.String()) + result := srv.builder.BuildResult(ev) + srv.store.Merge(result.Graph) + srv.counters.Inc(!ev.Outcome.Success) + if result.Span != nil { + srv.traceStore.Upsert(ev.Request.TraceID, core.ID("request", ev.Request.TraceID), result.Span) } } @@ -959,119 +933,6 @@ func keepAllSampler() *sampler.Sampler { return sampler.New(sampler.Config{HappySampleRatePct: 100}) } -func TestEvents_MetricsIncremented(t *testing.T) { - reg := prometheus.NewRegistry() - m := metrics.New(reg) - - srv := NewServer(ServerConfig{ - Store: graphstore.NewStore(), - Metrics: m, - Sampler: keepAllSampler(), - }) - - body := `{"schema_version":"1.0","event_name":"test.request","timestamp":"` + time.Now().UTC().Format(time.RFC3339) + `","user":{"id":"u1"},"request":{"trace_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"},"system":{"service":"test","env":"prod"},"outcome":{"success":true,"status_code":200,"kind":"http"},"metrics":{"latency_ms":10}}` - req := httptest.NewRequest(http.MethodPost, "/v1/events", strings.NewReader(body)) - w := httptest.NewRecorder() - srv.Events(w, req) - - if w.Code != http.StatusAccepted { - t.Fatalf("expected 202, got %d: %s", w.Code, w.Body.String()) - } - - families, err := reg.Gather() - if err != nil { - t.Fatal(err) - } - fm := gatherMap(families) - - if v := counterValue(fm["waylog_events_accepted_total"]); v < 1 { - t.Errorf("events_accepted_total = %v, want >= 1", v) - } - if v := histogramCount(fm["waylog_ingest_latency_seconds"]); v < 1 { - t.Errorf("ingest_latency count = %v, want >= 1", v) - } - if v := histogramCount(fm["waylog_merge_latency_seconds"]); v < 1 { - t.Errorf("merge_latency count = %v, want >= 1", v) - } -} - -func TestEvents_RejectedMetrics(t *testing.T) { - reg := prometheus.NewRegistry() - m := metrics.New(reg) - - srv := NewServer(ServerConfig{ - Store: graphstore.NewStore(), - Metrics: m, - }) - - // Invalid JSON - req := httptest.NewRequest(http.MethodPost, "/v1/events", strings.NewReader("{bad")) - w := httptest.NewRecorder() - srv.Events(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", w.Code) - } - - families, err := reg.Gather() - if err != nil { - t.Fatal(err) - } - fm := gatherMap(families) - - mf := fm["waylog_events_rejected_total"] - if mf == nil { - t.Fatal("waylog_events_rejected_total not found") - } - found := false - for _, m := range mf.GetMetric() { - for _, lp := range m.GetLabel() { - if lp.GetName() == "reason" && lp.GetValue() == "validation" { - if m.GetCounter().GetValue() >= 1 { - found = true - } - } - } - } - if !found { - t.Error("events_rejected_total{reason=validation} not >= 1") - } -} - -func TestEvents_EventlogWriteFailRejects(t *testing.T) { - dir := t.TempDir() - el, err := eventlog.New(dir) - if err != nil { - t.Fatal(err) - } - // Close the writer so subsequent writes fail. - el.Close() - - srv := NewServer(ServerConfig{ - Store: graphstore.NewStore(), - Sampler: keepAllSampler(), - }) - srv.EventLog = el - - body := `{"schema_version":"1.0","event_name":"test.request","timestamp":"` + time.Now().UTC().Format(time.RFC3339) + `","user":{"id":"u1"},"request":{"trace_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"},"system":{"service":"test","env":"prod"},"outcome":{"success":true,"status_code":200,"kind":"http"},"metrics":{"latency_ms":10}}` - req := httptest.NewRequest(http.MethodPost, "/v1/events", strings.NewReader(body)) - w := httptest.NewRecorder() - srv.Events(w, req) - - if w.Code != http.StatusServiceUnavailable { - t.Errorf("expected 503 when eventlog write fails, got %d", w.Code) - } - // Event should NOT have been merged into the store. - if srv.AcceptedCount() != 0 { - t.Errorf("accepted = %d, want 0 (event should be rejected)", srv.AcceptedCount()) - } - // Unsampled counters should NOT have been incremented. - total, errs := srv.counters.Sum(time.Hour) - if total != 0 || errs != 0 { - t.Errorf("counters = (%d, %d), want (0, 0) after WAL failure", total, errs) - } -} - func TestOverviewTimeseries(t *testing.T) { srv := makeTestServer() diff --git a/internal/ingest/v2/envelope.go b/internal/ingest/v2/envelope.go new file mode 100644 index 0000000..f5d11f3 --- /dev/null +++ b/internal/ingest/v2/envelope.go @@ -0,0 +1,47 @@ +// Package ingestv2 implements the schema-2.0 ingest endpoint at POST /v1/events +// per docs/v2-plan.md §5.1.1 and §5.1.2. +package ingestv2 + +// IngestEnvelope is the response body §5.1.2 defines for POST /v1/events on +// 200 and on the partial-failure 4xx paths that still return per-event +// rejection detail. Both Rejected and Deprecations must be non-nil on the +// wire so SDK envelope parsers see [] / {} consistently. +type IngestEnvelope struct { + Accepted int `json:"accepted"` + Duplicate int `json:"duplicate"` + Rejected []RejectedEvent `json:"rejected"` + Deprecations map[string]int `json:"deprecations"` +} + +// RejectedEvent describes one rejected entry inside a batch. Index is the +// zero-based position in the submitted batch. EventID is best-effort: the +// handler extracts it from the raw decoded body so callers can correlate +// even when validation failed. +type RejectedEvent struct { + Index int `json:"index"` + EventID string `json:"event_id,omitempty"` + Reason string `json:"reason"` + Detail string `json:"detail,omitempty"` +} + +// Reason codes returned in RejectedEvent.Reason. Stable, machine-readable +// strings; SDK consumers route on these. +const ( + ReasonInvalidJSON = "invalid_json" + ReasonSchemaValidationFailed = "schema_validation_failed" + ReasonBridgeNotImplemented = "bridge_not_implemented" + ReasonBatchOversize = "batch_oversize" + ReasonBodyOversize = "body_oversize" + ReasonUnsupportedContentType = "unsupported_content_type" + ReasonUnsupportedEncoding = "unsupported_content_encoding" + ReasonInvalidBody = "invalid_body" +) + +// newEnvelope returns a fresh response envelope with Rejected and +// Deprecations pre-allocated so JSON encoding emits [] / {} rather than null. +func newEnvelope() IngestEnvelope { + return IngestEnvelope{ + Rejected: []RejectedEvent{}, + Deprecations: map[string]int{}, + } +} diff --git a/internal/ingest/v2/handler.go b/internal/ingest/v2/handler.go new file mode 100644 index 0000000..73a953f --- /dev/null +++ b/internal/ingest/v2/handler.go @@ -0,0 +1,131 @@ +package ingestv2 + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/sssmaran/WaylogCLI/internal/metrics" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +// Handler serves the schema-2.0 ingest contract mounted at POST /v1/events. +type Handler struct { + schema *jsonschema.Schema + metrics *metrics.Metrics +} + +// New compiles the embedded v2.0 schema once for request-time reuse and +// pre-initializes the rejection-reason label series this handler emits so +// dashboards see zero-valued series before the first hit. +func New(m *metrics.Metrics) (*Handler, error) { + sch, err := eventv2.CompileEmbeddedSchema() + if err != nil { + return nil, err + } + if m != nil { + for _, reason := range rejectionReasons { + m.EventsRejected.WithLabelValues(reason).Add(0) + } + } + return &Handler{schema: sch, metrics: m}, nil +} + +var rejectionReasons = []string{ + ReasonInvalidJSON, + ReasonSchemaValidationFailed, + ReasonBridgeNotImplemented, + ReasonBatchOversize, + ReasonBodyOversize, + ReasonUnsupportedContentType, + ReasonUnsupportedEncoding, + ReasonInvalidBody, +} + +// Events validates schema-2.0 JSON/NDJSON ingest requests and returns the +// §5.1.2 envelope. Slice 1 validates-and-discards; durability lands in Slice 2. +func (h *Handler) Events(w http.ResponseWriter, r *http.Request) { + start := time.Now() + if h.metrics != nil { + h.metrics.InFlightRequests.Inc() + defer h.metrics.InFlightRequests.Dec() + defer func() { h.metrics.IngestLatency.Observe(time.Since(start).Seconds()) }() + } + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + parsed, reqErr := parseRequestBody(w, r) + if reqErr != nil { + h.recordRejected(reqErr.reason) + h.writeError(w, reqErr) + return + } + if h.metrics != nil { + h.metrics.IngestBatchSize.Observe(float64(len(parsed.events))) + } + + env := newEnvelope() + for i, eventBody := range parsed.events { + rej, ok := h.validateEvent(i, eventBody) + if ok { + env.Accepted++ + continue + } + env.Rejected = append(env.Rejected, rej) + h.recordRejected(rej.Reason) + } + writeEnvelope(w, http.StatusOK, env) +} + +func (h *Handler) validateEvent(index int, eventBody []byte) (RejectedEvent, bool) { + var raw any + if err := json.Unmarshal(eventBody, &raw); err != nil { + return RejectedEvent{Index: index, Reason: ReasonInvalidJSON, Detail: err.Error()}, false + } + + eventID, schemaVersion := rawIdentifiers(raw) + if strings.HasPrefix(schemaVersion, "1.") { + return RejectedEvent{Index: index, EventID: eventID, Reason: ReasonBridgeNotImplemented, Detail: "v1.x bridge ships in Slice 4"}, false + } + if err := eventv2.ValidateAny(h.schema, raw); err != nil { + return RejectedEvent{Index: index, EventID: eventID, Reason: ReasonSchemaValidationFailed, Detail: err.Error()}, false + } + return RejectedEvent{}, true +} + +func rawIdentifiers(raw any) (eventID, schemaVersion string) { + obj, ok := raw.(map[string]any) + if !ok { + return "", "" + } + if id, ok := obj["event_id"].(string); ok { + eventID = id + } + if version, ok := obj["schema_version"].(string); ok { + schemaVersion = version + } + return eventID, schemaVersion +} + +func (h *Handler) writeError(w http.ResponseWriter, reqErr *requestError) { + env := newEnvelope() + env.Rejected = append(env.Rejected, RejectedEvent{Index: 0, Reason: reqErr.reason, Detail: reqErr.detail}) + writeEnvelope(w, reqErr.status, env) +} + +func (h *Handler) recordRejected(reason string) { + if h.metrics != nil { + h.metrics.EventsRejected.WithLabelValues(reason).Inc() + } +} + +func writeEnvelope(w http.ResponseWriter, status int, env IngestEnvelope) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(env) +} diff --git a/internal/ingest/v2/handler_test.go b/internal/ingest/v2/handler_test.go new file mode 100644 index 0000000..baaf239 --- /dev/null +++ b/internal/ingest/v2/handler_test.go @@ -0,0 +1,391 @@ +package ingestv2 + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/sssmaran/WaylogCLI/internal/auth" + "github.com/sssmaran/WaylogCLI/internal/metrics" +) + +func TestEventsSingleJSONValid(t *testing.T) { + h := newTestHandler(t, nil) + rec := post(t, h, "application/json", "", validEventJSON("00000000-0000-4000-8000-000000000001")) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var env IngestEnvelope + decodeEnvelope(t, rec, &env) + if env.Accepted != 1 || env.Duplicate != 0 || len(env.Rejected) != 0 || len(env.Deprecations) != 0 { + t.Fatalf("env=%+v", env) + } + var wire map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &wire); err != nil { + t.Fatal(err) + } + if string(wire["rejected"]) != "[]" { + t.Fatalf("rejected wire=%s want []", wire["rejected"]) + } + if string(wire["deprecations"]) != "{}" { + t.Fatalf("deprecations wire=%s want {}", wire["deprecations"]) + } +} + +func TestEventsRejectsV1BridgeNotImplemented(t *testing.T) { + raw := validEventMap("00000000-0000-4000-8000-000000000001") + raw["schema_version"] = "1.1" + + rec := post(t, newTestHandler(t, nil), "application/json", "", mustJSON(t, raw)) + env := expectEnvelopeStatus(t, rec, http.StatusOK) + if env.Accepted != 0 || len(env.Rejected) != 1 || env.Rejected[0].Reason != ReasonBridgeNotImplemented { + t.Fatalf("env=%+v", env) + } +} + +func TestEventsUnsupportedContentEncoding(t *testing.T) { + for _, encoding := range []string{"br", "deflate", "compress"} { + t.Run(encoding, func(t *testing.T) { + rec := post(t, newTestHandler(t, nil), "application/json", encoding, validEventJSON("00000000-0000-4000-8000-000000000001")) + env := expectEnvelopeStatus(t, rec, http.StatusBadRequest) + if env.Rejected[0].Reason != ReasonUnsupportedEncoding { + t.Fatalf("reason=%q", env.Rejected[0].Reason) + } + }) + } +} + +func TestEventsBodySizeBoundaries(t *testing.T) { + exact := exactSizedJSON(t, maxBodyBytes) + rec := post(t, newTestHandler(t, nil), "application/json", "", exact) + env := expectEnvelopeStatus(t, rec, http.StatusOK) + if env.Accepted != 1 { + t.Fatalf("accepted=%d body=%s", env.Accepted, rec.Body.String()) + } + + tooLarge := exactSizedJSON(t, maxBodyBytes+1) + rec = post(t, newTestHandler(t, nil), "application/json", "", tooLarge) + env = expectEnvelopeStatus(t, rec, http.StatusRequestEntityTooLarge) + if env.Rejected[0].Reason != ReasonBodyOversize { + t.Fatalf("reason=%q", env.Rejected[0].Reason) + } + + rec = postBytes(t, newTestHandler(t, nil), "application/json", "gzip", gzipBytes([]byte(tooLarge))) + env = expectEnvelopeStatus(t, rec, http.StatusRequestEntityTooLarge) + if env.Rejected[0].Reason != ReasonBodyOversize { + t.Fatalf("reason=%q", env.Rejected[0].Reason) + } +} + +func TestEventsSchemaValidationFailures(t *testing.T) { + for _, field := range []string{"service", "ts_start", "ts_end"} { + t.Run(field, func(t *testing.T) { + raw := validEventMap("00000000-0000-4000-8000-000000000001") + delete(raw, field) + rec := post(t, newTestHandler(t, nil), "application/json", "", mustJSON(t, raw)) + env := expectEnvelopeStatus(t, rec, http.StatusOK) + if env.Accepted != 0 || len(env.Rejected) != 1 || env.Rejected[0].Reason != ReasonSchemaValidationFailed { + t.Fatalf("env=%+v", env) + } + if env.Rejected[0].Detail == "" || !strings.Contains(env.Rejected[0].Detail, field) { + t.Fatalf("detail=%q does not mention %s", env.Rejected[0].Detail, field) + } + }) + } +} + +func TestEventsNDJSONBatches(t *testing.T) { + h := newTestHandler(t, nil) + body := strings.Join([]string{ + validEventJSON("00000000-0000-4000-8000-000000000001"), + validEventJSON("00000000-0000-4000-8000-000000000002"), + validEventJSON("00000000-0000-4000-8000-000000000003"), + }, "\n") + "\n" + env := expectEnvelopeStatus(t, post(t, h, "application/x-ndjson", "", body), http.StatusOK) + if env.Accepted != 3 || len(env.Rejected) != 0 { + t.Fatalf("env=%+v", env) + } + + mixed := strings.Join([]string{ + validEventJSON("00000000-0000-4000-8000-000000000004"), + "{bad", + validEventJSON("00000000-0000-4000-8000-000000000005"), + }, "\n") + env = expectEnvelopeStatus(t, post(t, h, "application/x-ndjson", "", mixed), http.StatusOK) + if env.Accepted != 2 || len(env.Rejected) != 1 || env.Rejected[0].Index != 1 || env.Rejected[0].Reason != ReasonInvalidJSON { + t.Fatalf("env=%+v", env) + } + + env = expectEnvelopeStatus(t, postBytes(t, h, "application/x-ndjson", "gzip", gzipBytes([]byte(body))), http.StatusOK) + if env.Accepted != 3 { + t.Fatalf("env=%+v", env) + } +} + +func TestEventsBatchOversize(t *testing.T) { + var b strings.Builder + for i := 0; i < maxBatchItems+1; i++ { + b.WriteString(validEventJSON(fmt.Sprintf("00000000-0000-4000-8000-%012d", i+1))) + b.WriteByte('\n') + } + env := expectEnvelopeStatus(t, post(t, newTestHandler(t, nil), "application/x-ndjson", "", b.String()), http.StatusRequestEntityTooLarge) + if env.Rejected[0].Reason != ReasonBatchOversize { + t.Fatalf("reason=%q", env.Rejected[0].Reason) + } +} + +func TestEventsGzipInvalidBody(t *testing.T) { + rec := post(t, newTestHandler(t, nil), "application/x-ndjson", "gzip", "not gzip") + env := expectEnvelopeStatus(t, rec, http.StatusBadRequest) + if env.Rejected[0].Reason != ReasonInvalidBody { + t.Fatalf("reason=%q", env.Rejected[0].Reason) + } +} + +func TestEventsContentTypeHandling(t *testing.T) { + h := newTestHandler(t, nil) + expectEnvelopeStatus(t, post(t, h, "application/json; charset=utf-8", "", validEventJSON("00000000-0000-4000-8000-000000000001")), http.StatusOK) + + ndjson := validEventJSON("00000000-0000-4000-8000-000000000002") + "\n" + expectEnvelopeStatus(t, post(t, h, "application/x-ndjson; charset=utf-8", "", ndjson), http.StatusOK) + + rec := post(t, h, "", "", validEventJSON("00000000-0000-4000-8000-000000000003")) + env := expectEnvelopeStatus(t, rec, http.StatusBadRequest) + if env.Rejected[0].Reason != ReasonInvalidBody { + t.Fatalf("reason=%q", env.Rejected[0].Reason) + } + + rec = post(t, h, "text/plain", "", validEventJSON("00000000-0000-4000-8000-000000000004")) + env = expectEnvelopeStatus(t, rec, http.StatusUnsupportedMediaType) + if env.Rejected[0].Reason != ReasonUnsupportedContentType { + t.Fatalf("reason=%q", env.Rejected[0].Reason) + } +} + +func TestEventsAuthAndMethod(t *testing.T) { + h := newTestHandler(t, nil) + protected := auth.Middleware("write", []string{"test"}, nil)(http.HandlerFunc(h.Events)) + + req := httptest.NewRequest(http.MethodPost, "/v1/events", strings.NewReader(validEventJSON("00000000-0000-4000-8000-000000000001"))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + protected.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("unauth status=%d", rec.Code) + } + + req = httptest.NewRequest(http.MethodGet, "/v1/events", nil) + rec = httptest.NewRecorder() + h.Events(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("GET status=%d", rec.Code) + } +} + +func TestEventsConcurrentSchemaReuse(t *testing.T) { + h := newTestHandler(t, nil) + body := validEventJSON("00000000-0000-4000-8000-000000000001") + var wg sync.WaitGroup + errs := make(chan string, 64) + for i := 0; i < 64; i++ { + wg.Add(1) + go func() { + defer wg.Done() + rec := post(t, h, "application/json", "", body) + if rec.Code != http.StatusOK { + errs <- rec.Body.String() + } + }() + } + wg.Wait() + close(errs) + for err := range errs { + t.Fatal(err) + } +} + +func TestEventsMetrics(t *testing.T) { + reg := prometheus.NewRegistry() + m := metrics.New(reg) + h := newTestHandler(t, m) + + for _, n := range []int{1, 16, 256} { + var b strings.Builder + for i := 0; i < n; i++ { + b.WriteString(validEventJSON(fmt.Sprintf("00000000-0000-4000-8000-%012d", i+1))) + b.WriteByte('\n') + } + expectEnvelopeStatus(t, post(t, h, "application/x-ndjson", "", b.String()), http.StatusOK) + } + + fm := gatherMap(t, reg) + if got := histogramCount(fm["waylog_ingest_batch_size"]); got != 3 { + t.Fatalf("batch histogram count=%v want 3", got) + } + if got := counterWithLabel(fm["waylog_events_rejected_total"], "reason", ReasonSchemaValidationFailed); got != 0 { + t.Fatalf("schema_validation_failed preinit=%v want 0", got) + } + + raw := validEventMap("00000000-0000-4000-8000-000000000001") + delete(raw, "service") + expectEnvelopeStatus(t, post(t, h, "application/json", "", mustJSON(t, raw)), http.StatusOK) + fm = gatherMap(t, reg) + if got := counterWithLabel(fm["waylog_events_rejected_total"], "reason", ReasonSchemaValidationFailed); got != 1 { + t.Fatalf("schema_validation_failed=%v want 1", got) + } +} + +func newTestHandler(t *testing.T, m *metrics.Metrics) *Handler { + t.Helper() + h, err := New(m) + if err != nil { + t.Fatalf("New: %v", err) + } + return h +} + +func post(t *testing.T, h *Handler, contentType, encoding, body string) *httptest.ResponseRecorder { + t.Helper() + return postBytes(t, h, contentType, encoding, []byte(body)) +} + +func postBytes(t *testing.T, h *Handler, contentType, encoding string, body []byte) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/v1/events", bytes.NewReader(body)) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if encoding != "" { + req.Header.Set("Content-Encoding", encoding) + } + rec := httptest.NewRecorder() + h.Events(rec, req) + return rec +} + +func expectEnvelopeStatus(t *testing.T, rec *httptest.ResponseRecorder, status int) IngestEnvelope { + t.Helper() + if rec.Code != status { + t.Fatalf("status=%d want %d body=%s", rec.Code, status, rec.Body.String()) + } + var env IngestEnvelope + decodeEnvelope(t, rec, &env) + return env +} + +func decodeEnvelope(t *testing.T, rec *httptest.ResponseRecorder, env *IngestEnvelope) { + t.Helper() + if err := json.Unmarshal(rec.Body.Bytes(), env); err != nil { + t.Fatalf("decode envelope: %v body=%s", err, rec.Body.String()) + } + if env.Rejected == nil { + t.Fatal("rejected is nil") + } + if env.Deprecations == nil { + t.Fatal("deprecations is nil") + } +} + +func validEventMap(id string) map[string]any { + return map[string]any{ + "schema_version": "2.0", + "event_id": id, + "ts_start": "2026-04-25T14:00:00.000Z", + "ts_end": "2026-04-25T14:00:00.010Z", + "duration_ms": 10, + "kind": "http", + "service": "checkout", + "env": "test", + "trace_id": "11111111111111111111111111111111", + "span_id": "1111111111111111", + "parent_span_id": "", + "status": "ok", + "steps": []any{ + map[string]any{"name": "db.load_cart", "start_ms": 0, "duration_ms": 4, "status": "ok"}, + }, + "logs": []any{}, + "fields": map[string]any{"http": map[string]any{"method": "POST", "route": "/checkout", "status": 200}}, + "errors": []any{}, + } +} + +func validEventJSON(id string) string { + b, _ := json.Marshal(validEventMap(id)) + return string(b) +} + +func mustJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func exactSizedJSON(t *testing.T, size int) string { + t.Helper() + raw := validEventMap("00000000-0000-4000-8000-000000000001") + raw["padding"] = "" + base := mustJSON(t, raw) + if len(base) > size { + t.Fatalf("base event size %d exceeds target %d", len(base), size) + } + raw["padding"] = strings.Repeat("a", size-len(base)) + out := mustJSON(t, raw) + if len(out) != size { + t.Fatalf("padded JSON size %d != target %d", len(out), size) + } + return out +} + +func gzipBytes(body []byte) []byte { + var out bytes.Buffer + gz := gzip.NewWriter(&out) + _, _ = gz.Write(body) + _ = gz.Close() + return out.Bytes() +} + +func gatherMap(t *testing.T, reg *prometheus.Registry) map[string]*dto.MetricFamily { + t.Helper() + families, err := reg.Gather() + if err != nil { + t.Fatal(err) + } + out := map[string]*dto.MetricFamily{} + for _, mf := range families { + out[mf.GetName()] = mf + } + return out +} + +func histogramCount(mf *dto.MetricFamily) uint64 { + if mf == nil || len(mf.GetMetric()) == 0 || mf.GetMetric()[0].GetHistogram() == nil { + return 0 + } + return mf.GetMetric()[0].GetHistogram().GetSampleCount() +} + +func counterWithLabel(mf *dto.MetricFamily, label, value string) float64 { + if mf == nil { + return 0 + } + for _, metric := range mf.GetMetric() { + for _, lp := range metric.GetLabel() { + if lp.GetName() == label && lp.GetValue() == value && metric.GetCounter() != nil { + return metric.GetCounter().GetValue() + } + } + } + return 0 +} diff --git a/internal/ingest/v2/parse.go b/internal/ingest/v2/parse.go new file mode 100644 index 0000000..893249d --- /dev/null +++ b/internal/ingest/v2/parse.go @@ -0,0 +1,111 @@ +package ingestv2 + +import ( + "bytes" + "compress/gzip" + "errors" + "io" + "mime" + "net/http" + "strings" +) + +const ( + maxBodyBytes = 1 << 20 + maxBatchItems = 256 +) + +type parsedRequest struct { + events [][]byte +} + +type requestError struct { + status int + reason string + detail string +} + +func parseRequestBody(w http.ResponseWriter, r *http.Request) (parsedRequest, *requestError) { + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + reader, cleanup, reqErr := requestBodyReader(r) + if cleanup != nil { + defer cleanup() + } + if reqErr != nil { + return parsedRequest{}, reqErr + } + + body, err := io.ReadAll(io.LimitReader(reader, maxBodyBytes+1)) + if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + return parsedRequest{}, &requestError{status: http.StatusRequestEntityTooLarge, reason: ReasonBodyOversize, detail: "request body exceeds 1 MB"} + } + return parsedRequest{}, &requestError{status: http.StatusBadRequest, reason: ReasonInvalidBody, detail: err.Error()} + } + if len(body) > maxBodyBytes { + return parsedRequest{}, &requestError{status: http.StatusRequestEntityTooLarge, reason: ReasonBodyOversize, detail: "request body exceeds 1 MB"} + } + + mediaType, reqErr := requestMediaType(r) + if reqErr != nil { + return parsedRequest{}, reqErr + } + switch mediaType { + case "application/json": + return parsedRequest{events: [][]byte{body}}, nil + case "application/x-ndjson": + return parseNDJSON(body) + default: + return parsedRequest{}, &requestError{status: http.StatusUnsupportedMediaType, reason: ReasonUnsupportedContentType, detail: "unsupported content type " + mediaType} + } +} + +func requestBodyReader(r *http.Request) (io.Reader, func(), *requestError) { + encoding := strings.TrimSpace(r.Header.Get("Content-Encoding")) + if encoding == "" || strings.EqualFold(encoding, "identity") { + return r.Body, nil, nil + } + if !strings.EqualFold(encoding, "gzip") { + return nil, nil, &requestError{status: http.StatusBadRequest, reason: ReasonUnsupportedEncoding, detail: "unsupported content encoding " + encoding} + } + gz, err := gzip.NewReader(r.Body) + if err != nil { + return nil, nil, &requestError{status: http.StatusBadRequest, reason: ReasonInvalidBody, detail: err.Error()} + } + return gz, func() { _ = gz.Close() }, nil +} + +func requestMediaType(r *http.Request) (string, *requestError) { + raw := strings.TrimSpace(r.Header.Get("Content-Type")) + if raw == "" { + return "", &requestError{status: http.StatusBadRequest, reason: ReasonInvalidBody, detail: "missing content type"} + } + mediaType, _, err := mime.ParseMediaType(raw) + if err != nil { + return "", &requestError{status: http.StatusBadRequest, reason: ReasonInvalidBody, detail: err.Error()} + } + return strings.ToLower(mediaType), nil +} + +func parseNDJSON(body []byte) (parsedRequest, *requestError) { + events := make([][]byte, 0, maxBatchItems) + for len(body) > 0 { + line := body + if idx := bytes.IndexByte(body, '\n'); idx >= 0 { + line = body[:idx] + body = body[idx+1:] + } else { + body = nil + } + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + if len(events) == maxBatchItems { + return parsedRequest{}, &requestError{status: http.StatusRequestEntityTooLarge, reason: ReasonBatchOversize, detail: "batch exceeds 256 events"} + } + events = append(events, line) + } + return parsedRequest{events: events}, nil +} diff --git a/internal/ingest/v2/testdata/malformed-line.ndjson b/internal/ingest/v2/testdata/malformed-line.ndjson new file mode 100644 index 0000000..e2dd847 --- /dev/null +++ b/internal/ingest/v2/testdata/malformed-line.ndjson @@ -0,0 +1,2 @@ +{"schema_version":"2.0","event_id":"00000000-0000-4000-8000-000000000001","ts_start":"2026-04-25T14:00:00.000Z","ts_end":"2026-04-25T14:00:00.010Z","duration_ms":10,"kind":"http","service":"checkout","env":"test","trace_id":"11111111111111111111111111111111","span_id":"1111111111111111","parent_span_id":"","status":"ok"} +{"schema_version": diff --git a/internal/ingest/v2/testdata/mixed-validity.ndjson b/internal/ingest/v2/testdata/mixed-validity.ndjson new file mode 100644 index 0000000..1b7f1ba --- /dev/null +++ b/internal/ingest/v2/testdata/mixed-validity.ndjson @@ -0,0 +1,3 @@ +{"schema_version":"2.0","event_id":"00000000-0000-4000-8000-000000000001","ts_start":"2026-04-25T14:00:00.000Z","ts_end":"2026-04-25T14:00:00.010Z","duration_ms":10,"kind":"http","service":"checkout","env":"test","trace_id":"11111111111111111111111111111111","span_id":"1111111111111111","parent_span_id":"","status":"ok","steps":[],"logs":[],"fields":{},"errors":[]} +{bad +{"schema_version":"2.0","event_id":"00000000-0000-4000-8000-000000000002","ts_start":"2026-04-25T14:00:00.000Z","ts_end":"2026-04-25T14:00:00.010Z","duration_ms":10,"kind":"http","service":"checkout","env":"test","trace_id":"11111111111111111111111111111111","span_id":"1111111111111111","parent_span_id":"","status":"ok","steps":[],"logs":[],"fields":{},"errors":[]} diff --git a/internal/ingest/v2/testdata/oversize-batch.ndjson b/internal/ingest/v2/testdata/oversize-batch.ndjson new file mode 100644 index 0000000..faf2077 --- /dev/null +++ b/internal/ingest/v2/testdata/oversize-batch.ndjson @@ -0,0 +1,277 @@ +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 9d3266a..e0fff00 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -13,11 +13,12 @@ var defaultBuckets = []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, type Metrics struct { reg *prometheus.Registry - IngestLatency prometheus.Histogram - MergeLatency prometheus.Histogram - EventsAccepted prometheus.Counter - EventsRejected *prometheus.CounterVec - EventlogFails prometheus.Counter + IngestLatency prometheus.Histogram + IngestBatchSize prometheus.Histogram + MergeLatency prometheus.Histogram + EventsAccepted prometheus.Counter + EventsRejected *prometheus.CounterVec + EventlogFails prometheus.Counter ReplayLagSeconds prometheus.Gauge ReplayInProgress prometheus.Gauge @@ -76,6 +77,11 @@ func New(reg *prometheus.Registry) *Metrics { Help: "Full Events handler latency.", Buckets: defaultBuckets, }) + m.IngestBatchSize = prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "waylog_ingest_batch_size", + Help: "Number of events parsed from each ingest request.", + Buckets: []float64{1, 2, 4, 8, 16, 32, 64, 128, 256}, + }) m.MergeLatency = prometheus.NewHistogram(prometheus.HistogramOpts{ Name: "waylog_merge_latency_seconds", Help: "Build + Merge time.", @@ -89,6 +95,9 @@ func New(reg *prometheus.Registry) *Metrics { Name: "waylog_events_rejected_total", Help: "Dropped events.", }, []string{"reason"}) + for _, reason := range []string{"validation", "sampling"} { + m.EventsRejected.WithLabelValues(reason).Add(0) + } m.EventlogFails = prometheus.NewCounter(prometheus.CounterOpts{ Name: "waylog_eventlog_write_failures_total", Help: "Failed eventlog writes.", @@ -270,7 +279,7 @@ func New(reg *prometheus.Registry) *Metrics { }) reg.MustRegister( - m.IngestLatency, m.MergeLatency, + m.IngestLatency, m.IngestBatchSize, m.MergeLatency, m.EventsAccepted, m.EventsRejected, m.EventlogFails, m.ReplayLagSeconds, m.ReplayInProgress, m.ReplayFailuresTotal, m.Ready, m.InFlightRequests, diff --git a/packages/waylog-ts/src/__tests__/transport.test.ts b/packages/waylog-ts/src/__tests__/transport.test.ts index 4457bf8..198008b 100644 --- a/packages/waylog-ts/src/__tests__/transport.test.ts +++ b/packages/waylog-ts/src/__tests__/transport.test.ts @@ -77,6 +77,18 @@ describe("v2 transport", () => { expect(t.rejectedCount()).toBe(1); }); + it("counts deprecation headers even when the 2xx body is empty", async () => { + const t = new Transport({ + service: "checkout", + env: "test", + ingestUrl: "http://x", + fetch: vi.fn(async () => new Response("", { status: 202, headers: { Deprecation: "true" } })) as unknown as typeof fetch, + }); + t.submit(event("e1", "ok")); + await t.shutdown(); + expect(t.deprecatedCount()).toBe(1); + }); + it("retries transient failures", async () => { let calls = 0; const t = new Transport({ diff --git a/packages/waylog-ts/src/logger.ts b/packages/waylog-ts/src/logger.ts index ad57062..28735a4 100644 --- a/packages/waylog-ts/src/logger.ts +++ b/packages/waylog-ts/src/logger.ts @@ -81,6 +81,7 @@ class SDK { eventsDropped: 0, deliveryFailures: 0, eventsRejected: 0, + deprecatedSchemaResponses: 0, }; constructor(cfg: RequiredConfig) { @@ -128,6 +129,7 @@ export function stats(): Stats { eventsDropped: sdk.stats.eventsDropped + (sdk.transport?.droppedCount() ?? 0), deliveryFailures: sdk.stats.deliveryFailures + (sdk.transport?.failureCount() ?? 0), eventsRejected: sdk.stats.eventsRejected + (sdk.transport?.rejectedCount() ?? 0), + deprecatedSchemaResponses: sdk.stats.deprecatedSchemaResponses + (sdk.transport?.deprecatedCount() ?? 0), }; } @@ -633,5 +635,6 @@ function emptyStats(): Stats { eventsDropped: 0, deliveryFailures: 0, eventsRejected: 0, + deprecatedSchemaResponses: 0, }; } diff --git a/packages/waylog-ts/src/transport.ts b/packages/waylog-ts/src/transport.ts index 746250f..35c32ae 100644 --- a/packages/waylog-ts/src/transport.ts +++ b/packages/waylog-ts/src/transport.ts @@ -47,6 +47,7 @@ export class Transport { private dropped = 0; private failures = 0; private rejected = 0; + private deprecated = 0; private jsonPosts = new Set>(); constructor(config: WaylogConfig) { @@ -97,6 +98,10 @@ export class Transport { return this.rejected; } + deprecatedCount(): number { + return this.deprecated; + } + async shutdown(timeoutMs = 0): Promise { this.closed = true; if (this.timer) clearTimeout(this.timer); @@ -201,6 +206,7 @@ export class Transport { try { const resp = await this.fetchImpl(this.url, { method: "POST", headers, body }); if (resp.status >= 200 && resp.status < 300) { + this.recordResponseHeaders(resp); await this.recordEnvelope(resp); return { success: true, retryable: false, retryAfterMs: 0 }; } @@ -257,6 +263,7 @@ export class Transport { try { const resp = await this.fetchImpl(this.url, { method: "POST", headers, body: JSON.stringify(ev) }); if (resp.status >= 200 && resp.status < 300) { + this.recordResponseHeaders(resp); await this.recordEnvelope(resp); return; } @@ -303,6 +310,10 @@ export class Transport { } this.rejected += env.rejected?.length ?? 0; } + + private recordResponseHeaders(resp: Response): void { + if (resp.headers.get("Deprecation") || resp.headers.get("Sunset")) this.deprecated++; + } } export function normalizeIngestUrl(raw: string): string { diff --git a/packages/waylog-ts/src/types.ts b/packages/waylog-ts/src/types.ts index 8ce666c..7205162 100644 --- a/packages/waylog-ts/src/types.ts +++ b/packages/waylog-ts/src/types.ts @@ -38,6 +38,7 @@ export interface Stats { eventsDropped: number; deliveryFailures: number; eventsRejected: number; + deprecatedSchemaResponses: number; } export interface Anchor { diff --git a/pkg/event/v2/bridge_test.go b/pkg/event/v2/bridge_test.go index 314661a..c472a1c 100644 --- a/pkg/event/v2/bridge_test.go +++ b/pkg/event/v2/bridge_test.go @@ -56,7 +56,7 @@ func TestV11BridgeSchemaAcceptsCurrentV1Event(t *testing.T) { t.Fatalf("v1.1 schema missing at %s: %v", schemaPath, err) } - sch, err := compileSchema(schemaPath) + sch, err := CompileSchema(schemaPath) if err != nil { t.Fatalf("compile v1.1 schema: %v", err) } @@ -84,7 +84,7 @@ func TestV11BridgeSchemaRejectsMissingTraceID(t *testing.T) { } schemaPath, _ := filepath.Abs("../../../docs/schema/v1.1.json") - sch, err := compileSchema(schemaPath) + sch, err := CompileSchema(schemaPath) if err != nil { t.Fatalf("compile: %v", err) } @@ -101,7 +101,7 @@ func TestV20SchemaRequiresStatus(t *testing.T) { if err != nil { t.Fatal(err) } - sch, err := compileSchema(schemaPath) + sch, err := CompileSchema(schemaPath) if err != nil { t.Fatalf("compile v2.0 schema: %v", err) } diff --git a/pkg/event/v2/embed.go b/pkg/event/v2/embed.go new file mode 100644 index 0000000..76f0ffc --- /dev/null +++ b/pkg/event/v2/embed.go @@ -0,0 +1,25 @@ +package eventv2 + +import ( + _ "embed" + + "github.com/santhosh-tekuri/jsonschema/v5" +) + +//go:embed v2.0.schema.json +var embeddedSchema []byte + +// EmbeddedSchema returns the bytes of the v2.0 JSON Schema that ships with +// this package. The canonical copy lives at docs/schema/v2.0.json; the file +// next to this one is a build-time mirror so the runtime binary doesn't need +// the docs tree at startup. A drift test asserts the two stay byte-identical. +func EmbeddedSchema() []byte { + return embeddedSchema +} + +// CompileEmbeddedSchema compiles the embedded v2.0 schema once. Callers should +// hold the returned *jsonschema.Schema for the process lifetime and reuse it +// across requests via ValidateAny. +func CompileEmbeddedSchema() (*jsonschema.Schema, error) { + return compileFromBytes(embeddedSchema) +} diff --git a/pkg/event/v2/embed_test.go b/pkg/event/v2/embed_test.go new file mode 100644 index 0000000..3d4b3ea --- /dev/null +++ b/pkg/event/v2/embed_test.go @@ -0,0 +1,53 @@ +package eventv2_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +// TestEmbeddedSchemaMatchesDocs guards against drift between the canonical +// docs/schema/v2.0.json and the build-time mirror at pkg/event/v2/v2.0.schema.json. +// The runtime binary uses the embedded copy; updates to the docs copy must +// be mirrored or the runtime validates against a stale schema. +func TestEmbeddedSchemaMatchesDocs(t *testing.T) { + // Walk up from this package to the repo root, then read docs/schema/v2.0.json. + repoRoot, err := findRepoRoot() + if err != nil { + t.Fatalf("locate repo root: %v", err) + } + docsPath := filepath.Join(repoRoot, "docs", "schema", "v2.0.json") + docs, err := os.ReadFile(docsPath) + if err != nil { + t.Fatalf("read docs schema: %v", err) + } + if !bytes.Equal(docs, eventv2.EmbeddedSchema()) { + t.Fatalf("embedded schema drifted from %s — mirror docs/schema/v2.0.json into pkg/event/v2/v2.0.schema.json", docsPath) + } +} + +func findRepoRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.work")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", os.ErrNotExist + } + dir = parent + } +} + +func TestCompileEmbeddedSchemaSucceeds(t *testing.T) { + if _, err := eventv2.CompileEmbeddedSchema(); err != nil { + t.Fatalf("CompileEmbeddedSchema: %v", err) + } +} diff --git a/pkg/event/v2/event.go b/pkg/event/v2/event.go index b4e2a63..43fd89d 100644 --- a/pkg/event/v2/event.go +++ b/pkg/event/v2/event.go @@ -103,7 +103,12 @@ type ErrorRef struct { } // Validate checks an in-memory Event against the v2.0 JSON Schema at schemaPath. +// Callers in hot paths should prefer CompileSchema once + ValidateAny per event. func Validate(schemaPath string, e *Event) error { + sch, err := CompileSchema(schemaPath) + if err != nil { + return err + } raw, err := json.Marshal(e) if err != nil { return err @@ -112,11 +117,15 @@ func Validate(schemaPath string, e *Event) error { if err := json.Unmarshal(raw, &v); err != nil { return err } - return validateAny(schemaPath, v) + return ValidateAny(sch, v) } // ValidateFile reads a JSON file from disk and validates it against schemaPath. func ValidateFile(schemaPath, eventPath string) error { + sch, err := CompileSchema(schemaPath) + if err != nil { + return err + } raw, err := os.ReadFile(eventPath) if err != nil { return fmt.Errorf("read event: %w", err) @@ -125,24 +134,31 @@ func ValidateFile(schemaPath, eventPath string) error { if err := json.Unmarshal(raw, &v); err != nil { return fmt.Errorf("parse event: %w", err) } - return validateAny(schemaPath, v) + return ValidateAny(sch, v) } -func validateAny(schemaPath string, v any) error { - sch, err := compileSchema(schemaPath) - if err != nil { - return err - } - return sch.Validate(v) +// ValidateAny runs a previously-compiled schema against an arbitrary decoded +// JSON value (typically map[string]any from json.Unmarshal). Validating the +// raw decoded shape — rather than a typed struct — avoids zero-value masking +// of missing required fields. +func ValidateAny(schema *jsonschema.Schema, v any) error { + return schema.Validate(v) } const schemaResourceID = "schema" -func compileSchema(schemaPath string) (*jsonschema.Schema, error) { +// CompileSchema reads and compiles a JSON Schema document at schemaPath. +// The returned *jsonschema.Schema is safe for concurrent reuse across many +// ValidateAny calls; callers should compile once at startup. +func CompileSchema(schemaPath string) (*jsonschema.Schema, error) { raw, err := os.ReadFile(schemaPath) if err != nil { return nil, fmt.Errorf("read schema: %w", err) } + return compileFromBytes(raw) +} + +func compileFromBytes(raw []byte) (*jsonschema.Schema, error) { c := jsonschema.NewCompiler() if err := c.AddResource(schemaResourceID, bytes.NewReader(raw)); err != nil { return nil, fmt.Errorf("add schema resource: %w", err) diff --git a/pkg/event/v2/v2.0.schema.json b/pkg/event/v2/v2.0.schema.json new file mode 100644 index 0000000..7cdbe97 --- /dev/null +++ b/pkg/event/v2/v2.0.schema.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://waylog.dev/schema/v2.0.json", + "title": "Waylog Wide Event v2.0", + "type": "object", + "required": ["schema_version", "event_id", "ts_start", "ts_end", "kind", "service", "env", "trace_id", "status"], + "properties": { + "schema_version": { "const": "2.0" }, + "event_id": { "type": "string", "format": "uuid" }, + "ts_start": { "type": "string", "format": "date-time" }, + "ts_end": { "type": "string", "format": "date-time" }, + "duration_ms": { "type": "integer", "minimum": 0 }, + "kind": { "const": "http" }, + "service": { "type": "string", "minLength": 1 }, + "env": { "type": "string", "minLength": 1 }, + "version": { "type": "string" }, + "trace_id": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "span_id": { "type": "string", "pattern": "^([0-9a-f]{16})?$" }, + "parent_span_id": { "type": "string", "pattern": "^([0-9a-f]{16})?$" }, + "status": { "enum": ["ok", "error", "timeout", "partial", "aborted", "suppressed"] }, + "anchor": { + "type": "object", + "required": ["step", "error_code"], + "properties": { + "step": { "type": "string", "minLength": 1 }, + "error_code": { "type": "string", "minLength": 1 }, + "kind": { "type": "string" } + } + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "start_ms", "duration_ms", "status"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "span_id": { "type": "string", "pattern": "^([0-9a-f]{16})?$" }, + "start_ms": { "type": "integer", "minimum": 0 }, + "duration_ms": { "type": "integer", "minimum": 0 }, + "status": { "enum": ["ok", "error"] }, + "downstream": { + "type": "object", + "properties": { + "service": { "type": "string" }, + "endpoint": { "type": "string" }, + "kind": { "type": "string" } + } + }, + "error": { + "type": "object", + "properties": { + "code": { "type": "string" }, + "reason": { "type": "string" }, + "cause": { "type": "string" } + } + } + }, + "allOf": [ + { + "if": { "required": ["downstream"] }, + "then": { "required": ["span_id"] } + } + ] + } + }, + "logs": { + "type": "array", + "items": { + "type": "object", + "required": ["ts_offset_ms", "level", "msg"], + "properties": { + "ts_offset_ms": { "type": "integer", "minimum": 0 }, + "level": { "enum": ["info", "warn", "error"] }, + "msg": { "type": "string" }, + "fields": { "type": "object" } + } + } + }, + "fields": { "type": "object" }, + "errors": { + "type": "array", + "items": { + "type": "object", + "required": ["code"], + "properties": { + "code": { "type": "string" }, + "reason": { "type": "string" } + } + } + } + }, + "allOf": [ + { + "if": { "properties": { "status": { "enum": ["error", "timeout", "partial", "aborted"] } } }, + "then": { "required": ["anchor"] } + } + ] +} diff --git a/pkg/transport/http/batch.go b/pkg/transport/http/batch.go index 32f5e3c..a10ed6a 100644 --- a/pkg/transport/http/batch.go +++ b/pkg/transport/http/batch.go @@ -67,6 +67,7 @@ func (c *Client) flushBatch(batch []*eventv2.Event) deliveryResult { switch { case resp.StatusCode >= 200 && resp.StatusCode < 300: + c.recordResponseHeaders(resp.Header) c.recordEnvelope(respBody) return deliveryResult{success: true} case isRetryableStatus(resp.StatusCode): diff --git a/pkg/transport/http/client.go b/pkg/transport/http/client.go index af1f88f..913c151 100644 --- a/pkg/transport/http/client.go +++ b/pkg/transport/http/client.go @@ -42,9 +42,10 @@ type Client struct { queue *queue closed sync.Once - dropped atomic.Int64 - failures atomic.Int64 - rejected atomic.Int64 + dropped atomic.Int64 + failures atomic.Int64 + rejected atomic.Int64 + deprecated atomic.Int64 } func New(cfg Config) (*Client, error) { @@ -162,6 +163,7 @@ func (c *Client) submitSingle(ev *eventv2.Event) bool { _ = resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { before := c.Rejected() + c.recordResponseHeaders(resp.Header) c.recordEnvelope(respBody) return c.Rejected() == before } @@ -194,6 +196,19 @@ func (c *Client) Rejected() int64 { return c.rejected.Load() } +func (c *Client) Deprecated() int64 { + if c == nil { + return 0 + } + return c.deprecated.Load() +} + +func (c *Client) recordResponseHeaders(h http.Header) { + if h.Get("Deprecation") != "" || h.Get("Sunset") != "" { + c.deprecated.Add(1) + } +} + func (c *Client) recordDrop(n int) { if n > 0 { c.dropped.Add(int64(n)) diff --git a/pkg/transport/http/client_test.go b/pkg/transport/http/client_test.go index 4bbbce9..f649fe6 100644 --- a/pkg/transport/http/client_test.go +++ b/pkg/transport/http/client_test.go @@ -81,6 +81,25 @@ func TestSinglePostCountsEnvelopeRejections(t *testing.T) { } } +func TestSinglePostCountsDeprecationHeadersWithEmptyBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Deprecation", "true") + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + + cli, err := New(Config{IngestURL: srv.URL}) + if err != nil { + t.Fatalf("New: %v", err) + } + if !cli.Submit(validEvent("e1", eventv2.StatusOK)) { + t.Fatal("single submit should succeed") + } + if got := cli.Deprecated(); got != 1 { + t.Fatalf("Deprecated=%d want 1", got) + } +} + func TestNDJSONBatchFlush(t *testing.T) { var batches atomic.Int64 var totalEvents atomic.Int64 @@ -140,6 +159,24 @@ func TestNDJSONBatchCountsEnvelopeRejections(t *testing.T) { } } +func TestNDJSONBatchCountsDeprecationHeadersWithEmptyBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Sunset", "Wed, 01 Jan 2027 00:00:00 GMT") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cli, err := New(Config{IngestURL: srv.URL, BatchMode: true, BatchAgeMs: 1}) + if err != nil { + t.Fatalf("New: %v", err) + } + cli.Submit(validEvent("e1", eventv2.StatusOK)) + cli.Shutdown(2 * time.Second) + if got := cli.Deprecated(); got != 1 { + t.Fatalf("Deprecated=%d want 1", got) + } +} + func TestQueueEvictsOKBeforePriority(t *testing.T) { var flushed []string q := newQueue(Config{ diff --git a/pkg/waylog/v2/waylog.go b/pkg/waylog/v2/waylog.go index f7c042d..9fa18ab 100644 --- a/pkg/waylog/v2/waylog.go +++ b/pkg/waylog/v2/waylog.go @@ -63,19 +63,20 @@ const ( // Go disallows a type and a free function sharing a name at package scope, so // the spec's `func Stats() Stats` is rendered here as `func Stats() StatsSnapshot`. type StatsSnapshot struct { - ActiveRequests int64 - EventsEmitted int64 // total final events successfully written (includes suppressed) - EventsSuppressed int64 // subset of EventsEmitted with status=suppressed - StepsDropped int64 - LogsDropped int64 - BytesDroppedFromBuffer int64 - BufferOverflows int64 // requests degraded to header-only by MaxBufferBytes pressure - ReservedCodeRejections int64 // NewError/Fail/Step returns with WAYLOG_* codes - SuppressedThenFailed int64 - LateCompletionAfterEmit int64 - EventsDropped int64 - DeliveryFailures int64 - EventsRejected int64 + ActiveRequests int64 + EventsEmitted int64 // total final events successfully written (includes suppressed) + EventsSuppressed int64 // subset of EventsEmitted with status=suppressed + StepsDropped int64 + LogsDropped int64 + BytesDroppedFromBuffer int64 + BufferOverflows int64 // requests degraded to header-only by MaxBufferBytes pressure + ReservedCodeRejections int64 // NewError/Fail/Step returns with WAYLOG_* codes + SuppressedThenFailed int64 + LateCompletionAfterEmit int64 + EventsDropped int64 + DeliveryFailures int64 + EventsRejected int64 + DeprecatedSchemaResponses int64 } // ErrAlreadyInitialized is returned by Init when a prior SDK is still alive @@ -222,19 +223,20 @@ func Stats() StatsSnapshot { active := int64(len(s.active)) s.mu.Unlock() return StatsSnapshot{ - ActiveRequests: active, - EventsEmitted: s.emitted.Load(), - EventsSuppressed: s.suppressed.Load(), - StepsDropped: s.stepsDropped.Load(), - LogsDropped: s.logsDropped.Load(), - BytesDroppedFromBuffer: s.bytesDropped.Load(), - BufferOverflows: s.bufferOverflows.Load(), - ReservedCodeRejections: s.reservedRejected.Load(), - SuppressedThenFailed: s.suppressFailed.Load(), - LateCompletionAfterEmit: s.lateAfterEmit.Load(), - EventsDropped: s.eventsDropped.Load() + deliveryDropped(s), - DeliveryFailures: deliveryFailures(s), - EventsRejected: deliveryRejected(s), + ActiveRequests: active, + EventsEmitted: s.emitted.Load(), + EventsSuppressed: s.suppressed.Load(), + StepsDropped: s.stepsDropped.Load(), + LogsDropped: s.logsDropped.Load(), + BytesDroppedFromBuffer: s.bytesDropped.Load(), + BufferOverflows: s.bufferOverflows.Load(), + ReservedCodeRejections: s.reservedRejected.Load(), + SuppressedThenFailed: s.suppressFailed.Load(), + LateCompletionAfterEmit: s.lateAfterEmit.Load(), + EventsDropped: s.eventsDropped.Load() + deliveryDropped(s), + DeliveryFailures: deliveryFailures(s), + EventsRejected: deliveryRejected(s), + DeprecatedSchemaResponses: deliveryDeprecated(s), } } @@ -286,6 +288,13 @@ func deliveryRejected(s *sdk) int64 { return s.delivery.Rejected() } +func deliveryDeprecated(s *sdk) int64 { + if s == nil || s.delivery == nil { + return 0 + } + return s.delivery.Deprecated() +} + func resetForTest() { stateMu.Lock() previous := state diff --git a/tests/integration/helpers_test.go b/tests/integration/helpers_test.go index f9712c9..b51ec6c 100644 --- a/tests/integration/helpers_test.go +++ b/tests/integration/helpers_test.go @@ -2,6 +2,7 @@ package integration import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -10,14 +11,23 @@ import ( "time" "github.com/sssmaran/WaylogCLI/internal/coldstore" + "github.com/sssmaran/WaylogCLI/internal/graph/core" graphstore "github.com/sssmaran/WaylogCLI/internal/graph/store" "github.com/sssmaran/WaylogCLI/internal/ingest" "github.com/sssmaran/WaylogCLI/internal/testutil" "github.com/sssmaran/WaylogCLI/internal/tools" + "github.com/sssmaran/WaylogCLI/internal/tracestore" "github.com/sssmaran/WaylogCLI/pkg/event" ) -func newIntegrationServer(t *testing.T) (*ingest.Server, *coldstore.SQLiteStore, *coldstore.BatchWriter) { +type integrationServer struct { + *ingest.Server + traceStore *tracestore.Store + coldStore *coldstore.SQLiteStore + coldWriter *coldstore.BatchWriter +} + +func newIntegrationServer(t *testing.T) (*integrationServer, *coldstore.SQLiteStore, *coldstore.BatchWriter) { t.Helper() managed, err := coldstore.Open(":memory:") @@ -41,8 +51,10 @@ func newIntegrationServer(t *testing.T) (*ingest.Server, *coldstore.SQLiteStore, dedup := ingest.NewDedupCache() + ts := tracestore.NewStore() srv := ingest.NewServer(ingest.ServerConfig{ Store: graphstore.NewStore(), + TraceStore: ts, AskRegistry: reg, DedupCache: dedup, ColdWriter: bw, @@ -51,23 +63,35 @@ func newIntegrationServer(t *testing.T) (*ingest.Server, *coldstore.SQLiteStore, PlanStore: ingest.NewPlanStore(), }) - return srv, cs, bw + return &integrationServer{Server: srv, traceStore: ts, coldStore: cs, coldWriter: bw}, cs, bw } -func ingestEvent(t *testing.T, srv *ingest.Server, ev event.WideEvent) int { +func ingestEvent(t *testing.T, srv *integrationServer, ev event.WideEvent) int { t.Helper() - body, err := json.Marshal(ev) - if err != nil { - t.Fatal(err) + result := srv.Builder().BuildResult(ev) + srv.Store().Merge(result.Graph) + if result.Span != nil { + srv.traceStore.Upsert(ev.Request.TraceID, core.ID("request", ev.Request.TraceID), result.Span) } - req := httptest.NewRequest(http.MethodPost, "/v1/events", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.Events(w, req) - return w.Code + srv.Counters().Inc(!ev.Outcome.Success) + srv.AcceptedPtr().Add(1) + if srv.coldWriter != nil { + srv.coldWriter.Enqueue(ev) + } + if ev.System.DeploymentID != "" && srv.coldStore != nil { + _ = srv.coldStore.UpsertDeployment(context.Background(), coldstore.Deployment{ + ID: ev.System.DeploymentID, + Service: ev.System.Service, + Version: ev.System.Version, + Env: ev.System.Env, + FirstSeen: ev.Timestamp, + LastSeen: ev.Timestamp, + }) + } + return http.StatusAccepted } -func ingestEvents(t *testing.T, srv *ingest.Server, events []event.WideEvent) { +func ingestEvents(t *testing.T, srv *integrationServer, events []event.WideEvent) { t.Helper() for i, ev := range events { code := ingestEvent(t, srv, ev) diff --git a/tests/integration/otlp_test.go b/tests/integration/otlp_test.go index 2451f15..3d1885a 100644 --- a/tests/integration/otlp_test.go +++ b/tests/integration/otlp_test.go @@ -49,7 +49,7 @@ func resourceSpansFor(service string, traceID, spanID, parentSpanID []byte, name // newOTLPHandler builds an OTLP handler that shares store/builder/sampler/etc. // with the provided integration server, mirroring what cmd/ingest/main.go does. -func newOTLPHandler(t *testing.T, srv *ingest.Server, store *graphstore.Store) *otelhttp.Handler { +func newOTLPHandler(t *testing.T, srv *integrationServer, store *graphstore.Store) *otelhttp.Handler { t.Helper() // Use a 100% sampler so the test is deterministic regardless of the // HAPPY_SAMPLE_RATE_PCT env default the integration server picks up. From 595f3d02114967088824dce11380feef572ebae7 Mon Sep 17 00:00:00 2001 From: skota-hash Date: Tue, 28 Apr 2026 02:25:31 -0400 Subject: [PATCH 02/12] feat(imgest): Implemented v2 ingest durability and dedupe - Add schema-2.0 raw JSONL WAL writer under internal/eventlog/v2 - Write validated raw event JSON as one JSONL line - Reuse EVENT_LOG_SYNC and EVENT_LOG_MAX_FILE_MB - Rotate with events-YYYYMMDD-HHMMSS.jsonl naming plus suffixes - Expose ActivePath and idempotent Close - Add v2 WAL replay for event_id dedupe warmup - Read events-*.jsonl oldest-to-newest by mtime - Seed the recent event_id LRU before serving HTTP - Skip malformed, missing-ID, and oversized corrupt lines with warnings - Increment waylog_event_dedup_replay_loaded_total once at startup - Add ingestv2 event_id dedupe - Exact recent-ID LRU with default capacity 65536 - Configurable via WAYLOG_V2_DEDUP_CAPACITY - No read-promotion, bounded memory, mutex-safe - Track waylog_event_dedup_cache_size - Make dedupe atomic with WAL write to prevent concurrent duplicate writes - Make /v1/events durable for schema-2.0 ingest - Validate raw event shape against embedded v2 schema - Return duplicate hits without rewriting WAL - Write every accepted event to v2 WAL before responding - Add event_id to dedupe cache only after WAL success - Return plain 503 durability unavailable on WAL failure with no envelope - Increment waylog_events_accepted_total only after WAL success - Increment waylog_events_duplicate_total on dedupe hits - Rewrite /v1/events/validate onto the v2 parser and envelope - Reuse JSON, NDJSON, gzip, body-limit, and schema validation paths - Skip WAL and dedupe mutation - Return the same v2 envelope shape with duplicate always zero - Remove the legacy v1 validate handler and tests - Wire v2 durability into cmd/ingest - Compute EVENT_LOG_V2_DIR from explicit env, EVENT_LOG_DIR/v2, or ./data/eventlog-v2 - Initialize v2 WAL and dedupe before mounting routes - Warm dedupe synchronously before ListenAndServe - Mount /v1/events and /v1/events/validate behind existing write auth - Extend retention pruning to cover both v1 and v2 WAL dirs - Update metrics and docs - Update waylog_events_accepted_total help to durable WAL semantics - Add duplicate, dedupe cache size, and replay-loaded metrics - Add durability_unavailable rejection label for metrics only - Document accepted-as-durable semantics, EVENT_LOG_V2_DIR, and WAYLOG_V2_DEDUP_CAPACITY - Add CHANGELOG entry for Slice 2 behavior and validate response change - Add regression coverage - Dedup capacity, no read-promotion, gauge, and race tests - WAL append, rotation, sync, active path, and close tests - Replay ordering, malformed lines, 1 MB lines, oversized corrupt lines, and empty dir tests - Handler tests for first accept then duplicate, concurrent duplicate single-write, WAL 503, mid-batch failure, validate dry-run, and accepted metric semantics Verification: - go test ./... - go test ./pkg/... - go test -race ./internal/ingest ./internal/ingest/v2 ./internal/eventlog/v2 - git diff --check --- cmd/ingest/main.go | 83 +++++++-- docs/env.md | 2 + internal/eventlog/v2/replay.go | 141 +++++++++++++++ internal/eventlog/v2/replay_test.go | 140 +++++++++++++++ internal/eventlog/v2/writer.go | 132 ++++++++++++++ internal/eventlog/v2/writer_test.go | 88 ++++++++++ internal/ingest/handler.go | 38 ---- internal/ingest/handler_test.go | 52 ------ internal/ingest/pipeline.go | 4 +- internal/ingest/pipeline_test.go | 63 +++++++ internal/ingest/v2/dedup.go | 114 ++++++++++++ internal/ingest/v2/dedup_test.go | 80 +++++++++ internal/ingest/v2/envelope.go | 1 + internal/ingest/v2/handler.go | 95 ++++++++-- internal/ingest/v2/handler_test.go | 261 ++++++++++++++++++++++++++-- internal/metrics/metrics.go | 32 +++- 16 files changed, 1181 insertions(+), 145 deletions(-) create mode 100644 internal/eventlog/v2/replay.go create mode 100644 internal/eventlog/v2/replay_test.go create mode 100644 internal/eventlog/v2/writer.go create mode 100644 internal/eventlog/v2/writer_test.go create mode 100644 internal/ingest/v2/dedup.go create mode 100644 internal/ingest/v2/dedup_test.go diff --git a/cmd/ingest/main.go b/cmd/ingest/main.go index 972da4e..fe363a1 100644 --- a/cmd/ingest/main.go +++ b/cmd/ingest/main.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "strings" "syscall" "time" @@ -21,6 +22,7 @@ import ( "github.com/sssmaran/WaylogCLI/internal/dashboard" "github.com/sssmaran/WaylogCLI/internal/detect" "github.com/sssmaran/WaylogCLI/internal/eventlog" + eventlogv2 "github.com/sssmaran/WaylogCLI/internal/eventlog/v2" "github.com/sssmaran/WaylogCLI/internal/graph/causal" "github.com/sssmaran/WaylogCLI/internal/graph/core" graphstore "github.com/sssmaran/WaylogCLI/internal/graph/store" @@ -140,6 +142,41 @@ func main() { promReg := prometheus.NewRegistry() m := metrics.New(promReg) + eventLogSync := config.GetenvBool("EVENT_LOG_SYNC", true) + eventLogMaxMB := int64(config.GetenvInt("EVENT_LOG_MAX_FILE_MB", 50)) + eventLogRetention := config.GetenvDuration("EVENT_LOG_RETENTION", 72*time.Hour) + if eventLogRetention <= 0 { + slog.Error("EVENT_LOG_RETENTION must be positive", "value", eventLogRetention) + os.Exit(1) + } + eventLogV2Dir := config.Getenv("EVENT_LOG_V2_DIR", defaultEventLogV2Dir(eventLogDir)) + v2Wal, err := eventlogv2.New(eventLogV2Dir, + eventlogv2.WithSync(eventLogSync), + eventlogv2.WithMaxBytes(eventLogMaxMB*1024*1024), + ) + if err != nil { + slog.Error("eventlog v2 init failed", "err", err) + os.Exit(1) + } + defer v2Wal.Close() + + dedupCapacity := config.GetenvInt("WAYLOG_V2_DEDUP_CAPACITY", ingestv2.DefaultDedupCapacity) + v2Dedup := ingestv2.NewDedup(dedupCapacity, m.EventDedupCacheSize) + loaded, err := eventlogv2.WarmDedup(eventLogV2Dir, v2Dedup) + if err != nil { + slog.Error("eventlog v2 dedup replay failed", "err", err) + os.Exit(1) + } + m.EventDedupReplayLoaded.Add(float64(loaded)) + slog.Info("eventlog v2 enabled", + "dir", eventLogV2Dir, + "sync_per_write", eventLogSync, + "max_file_mb", eventLogMaxMB, + "retention", eventLogRetention, + "dedup_capacity", dedupCapacity, + "dedup_replay_loaded", loaded, + ) + // Optional SQLite cold store var coldDB coldstore.ManagedStore var coldWriter *coldstore.BatchWriter @@ -195,14 +232,7 @@ func main() { sseHub := ingest.NewSSEHub(config.GetenvInt("SSE_MAX_CLIENTS", 100)) ingestServer.SetSSEHub(sseHub) - // Optional append-only event log - eventLogSync := config.GetenvBool("EVENT_LOG_SYNC", true) - eventLogMaxMB := int64(config.GetenvInt("EVENT_LOG_MAX_FILE_MB", 50)) - eventLogRetention := config.GetenvDuration("EVENT_LOG_RETENTION", 72*time.Hour) - if eventLogRetention <= 0 { - slog.Error("EVENT_LOG_RETENTION must be positive", "value", eventLogRetention) - os.Exit(1) - } + // Optional append-only v1 event log var el *eventlog.Writer if eventLogDir != "" { var err error @@ -303,13 +333,17 @@ func main() { mux.Handle("/metrics", m.Handler()) // Write endpoints. - eventsV2, err := ingestv2.New(m) + eventsV2, err := ingestv2.New(ingestv2.Config{ + Metrics: m, + Dedup: v2Dedup, + WAL: v2Wal, + }) if err != nil { slog.Error("initialize v2 ingest handler", "err", err) os.Exit(1) } mux.Handle("/v1/events", writeAuth(http.HandlerFunc(eventsV2.Events))) - mux.Handle("/v1/events/validate", writeAuth(http.HandlerFunc(ingestServer.Validate))) + mux.Handle("/v1/events/validate", writeAuth(http.HandlerFunc(eventsV2.Validate))) // OTLP/HTTP traces — routed through a dedicated pipeline that reuses the // same store, builder, sampler, WAL, cold store, and SSE hub as the SDK @@ -522,7 +556,7 @@ func main() { // ---------------- Event log retention ---------------- - if el != nil { + if el != nil || v2Wal != nil { go func() { retTicker := time.NewTicker(5 * time.Minute) defer retTicker.Stop() @@ -531,11 +565,21 @@ func main() { case <-ctx.Done(): return case <-retTicker.C: - n, err := eventlog.PruneOlderThan(eventLogDir, eventLogRetention, el.ActivePath()) - if err != nil { - slog.Warn("eventlog retention cleanup error", "err", err) - } else if n > 0 { - slog.Info("eventlog retention cleanup", "deleted", n) + if el != nil { + n, err := eventlog.PruneOlderThan(eventLogDir, eventLogRetention, el.ActivePath()) + if err != nil { + slog.Warn("eventlog retention cleanup error", "err", err) + } else if n > 0 { + slog.Info("eventlog retention cleanup", "dir", eventLogDir, "deleted", n) + } + } + if v2Wal != nil { + n, err := eventlog.PruneOlderThan(eventLogV2Dir, eventLogRetention, v2Wal.ActivePath()) + if err != nil { + slog.Warn("eventlog v2 retention cleanup error", "err", err) + } else if n > 0 { + slog.Info("eventlog v2 retention cleanup", "dir", eventLogV2Dir, "deleted", n) + } } } } @@ -711,6 +755,13 @@ func replLoop() { } } +func defaultEventLogV2Dir(eventLogDir string) string { + if eventLogDir != "" { + return filepath.Join(eventLogDir, "v2") + } + return "./data/eventlog-v2" +} + func printHelp() { os.Stdout.WriteString("\033[1m\033[36mcommands:\033[0m\n") os.Stdout.WriteString(" waylog \"\"\n") diff --git a/docs/env.md b/docs/env.md index b5e5ea7..edbb2e6 100644 --- a/docs/env.md +++ b/docs/env.md @@ -42,9 +42,11 @@ Scoped keys. See the Auth section of the [README](../README.md). | `SNAPSHOT_PATH` | `./data/graph_snapshot.json` | Graph snapshot location | | `SQLITE_PATH` | — | SQLite cold store path (optional; disabled if empty) | | `EVENT_LOG_DIR` | — | Append-only event log directory (disabled if empty) | +| `EVENT_LOG_V2_DIR` | `${EVENT_LOG_DIR}/v2` or `./data/eventlog-v2` | Raw schema-2.0 WAL directory for `/v1/events` | | `EVENT_LOG_SYNC` | `true` | Per-write fsync. Set `false` for dev/load testing | | `EVENT_LOG_MAX_FILE_MB` | `50` | Rotation size. `0` disables rotation | | `EVENT_LOG_RETENTION` | `72h` | Event log retention. Must be positive | +| `WAYLOG_V2_DEDUP_CAPACITY` | `65536` | Recent schema-2.0 `event_id` dedupe cache capacity | | `GRAPH_RETENTION` | `24h` | Hot graph retention. Nodes older than this are pruned every snapshot tick | See [Internals](internals.md) for the full durability model. diff --git a/internal/eventlog/v2/replay.go b/internal/eventlog/v2/replay.go new file mode 100644 index 0000000..404fc96 --- /dev/null +++ b/internal/eventlog/v2/replay.go @@ -0,0 +1,141 @@ +package eventlogv2 + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + "time" + + ingestv2 "github.com/sssmaran/WaylogCLI/internal/ingest/v2" +) + +const maxReplayLineBytes = (1 << 20) + 1 + +func WarmDedup(dir string, d *ingestv2.Dedup) (int, error) { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return 0, nil + } + if err != nil { + return 0, err + } + + files := make([]replayFile, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasPrefix(entry.Name(), "events-") || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + info, err := entry.Info() + if err != nil { + return 0, err + } + files = append(files, replayFile{ + path: filepath.Join(dir, entry.Name()), + name: entry.Name(), + modTime: info.ModTime(), + }) + } + sort.Slice(files, func(i, j int) bool { + if files[i].modTime.Equal(files[j].modTime) { + return files[i].name < files[j].name + } + return files[i].modTime.Before(files[j].modTime) + }) + + loaded := 0 + for _, file := range files { + n, err := warmFile(file.path, d) + loaded += n + if err != nil { + return loaded, err + } + } + return loaded, nil +} + +type replayFile struct { + path string + name string + modTime time.Time +} + +func warmFile(path string, d *ingestv2.Dedup) (int, error) { + f, err := os.Open(path) + if err != nil { + return 0, err + } + defer f.Close() + + reader := bufio.NewReaderSize(f, 64*1024) + + loaded := 0 + lineNum := 0 + for { + line, tooLong, readErr := readReplayLine(reader) + if readErr == io.EOF && len(line) == 0 && !tooLong { + break + } + lineNum++ + if tooLong { + slog.Warn("eventlogv2: skipping oversized line", "file", path, "line", lineNum) + if readErr == io.EOF { + break + } + continue + } + if readErr != nil && readErr != io.EOF { + return loaded, readErr + } + var raw map[string]any + if err := json.Unmarshal(line, &raw); err != nil { + slog.Warn("eventlogv2: skipping malformed line", "file", path, "line", lineNum, "err", err) + if readErr == io.EOF { + break + } + continue + } + eventID, ok := raw["event_id"].(string) + if !ok || eventID == "" { + slog.Warn("eventlogv2: skipping line without event_id", "file", path, "line", lineNum) + if readErr == io.EOF { + break + } + continue + } + d.Add(eventID) + loaded++ + if readErr == io.EOF { + break + } + } + return loaded, nil +} + +func readReplayLine(r *bufio.Reader) ([]byte, bool, error) { + var line []byte + tooLong := false + for { + frag, err := r.ReadSlice('\n') + if !tooLong { + if len(line)+len(frag) > maxReplayLineBytes { + tooLong = true + line = nil + } else { + line = append(line, frag...) + } + } + if err == bufio.ErrBufferFull { + continue + } + if err != nil && err != io.EOF { + return nil, tooLong, err + } + return bytes.TrimRight(line, "\r\n"), tooLong, err + } +} diff --git a/internal/eventlog/v2/replay_test.go b/internal/eventlog/v2/replay_test.go new file mode 100644 index 0000000..a2b15dd --- /dev/null +++ b/internal/eventlog/v2/replay_test.go @@ -0,0 +1,140 @@ +package eventlogv2 + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + ingestv2 "github.com/sssmaran/WaylogCLI/internal/ingest/v2" +) + +func TestWarmDedupLoadsOldestFirstAndKeepsNewestAtCapacity(t *testing.T) { + dir := t.TempDir() + now := time.Now() + writeReplayFile(t, dir, "events-20260428-010000.jsonl", now.Add(-3*time.Hour), "a", "b") + writeReplayFile(t, dir, "events-20260428-020000.jsonl", now.Add(-2*time.Hour), "c", "d") + writeReplayFile(t, dir, "events-20260428-030000.jsonl", now.Add(-1*time.Hour), "e", "f") + + d := ingestv2.NewDedup(4, nil) + loaded, err := WarmDedup(dir, d) + if err != nil { + t.Fatal(err) + } + if loaded != 6 { + t.Fatalf("loaded=%d want 6", loaded) + } + for _, id := range []string{"a", "b"} { + if d.Seen(id) { + t.Fatalf("%s should have been evicted", id) + } + } + for _, id := range []string{"c", "d", "e", "f"} { + if !d.Seen(id) { + t.Fatalf("%s should be present", id) + } + } +} + +func TestWarmDedupSkipsMalformedLines(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "events-20260428-010000.jsonl") + body := strings.Join([]string{ + replayEventJSON(t, "a"), + "{bad", + `{"schema_version":"2.0"}`, + replayEventJSON(t, "b"), + }, "\n") + "\n" + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + d := ingestv2.NewDedup(10, nil) + loaded, err := WarmDedup(dir, d) + if err != nil { + t.Fatal(err) + } + if loaded != 2 || !d.Seen("a") || !d.Seen("b") { + t.Fatalf("loaded=%d seen(a)=%v seen(b)=%v", loaded, d.Seen("a"), d.Seen("b")) + } +} + +func TestWarmDedupEmptyDir(t *testing.T) { + loaded, err := WarmDedup(t.TempDir(), ingestv2.NewDedup(10, nil)) + if err != nil { + t.Fatal(err) + } + if loaded != 0 { + t.Fatalf("loaded=%d want 0", loaded) + } +} + +func TestWarmDedupAcceptsOneMBLine(t *testing.T) { + dir := t.TempDir() + raw := replayEventJSON(t, "a") + paddingLen := (1 << 20) - len(raw) - len(`,"padding":""`) + if paddingLen < 0 { + t.Fatalf("base fixture too large: %d", len(raw)) + } + withPadding := strings.TrimSuffix(raw, "}") + `,"padding":"` + strings.Repeat("a", paddingLen) + `"}` + if len(withPadding) > 1<<20 { + t.Fatalf("line=%d want <=1MB", len(withPadding)) + } + if err := os.WriteFile(filepath.Join(dir, "events-20260428-010000.jsonl"), []byte(withPadding+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + d := ingestv2.NewDedup(10, nil) + loaded, err := WarmDedup(dir, d) + if err != nil { + t.Fatal(err) + } + if loaded != 1 || !d.Seen("a") { + t.Fatalf("loaded=%d seen=%v", loaded, d.Seen("a")) + } +} + +func TestWarmDedupSkipsOversizedLineAndContinues(t *testing.T) { + dir := t.TempDir() + body := strings.Repeat("x", maxReplayLineBytes+1) + "\n" + replayEventJSON(t, "a") + "\n" + if err := os.WriteFile(filepath.Join(dir, "events-20260428-010000.jsonl"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + d := ingestv2.NewDedup(10, nil) + loaded, err := WarmDedup(dir, d) + if err != nil { + t.Fatal(err) + } + if loaded != 1 || !d.Seen("a") { + t.Fatalf("loaded=%d seen=%v", loaded, d.Seen("a")) + } +} + +func writeReplayFile(t *testing.T, dir, name string, modTime time.Time, ids ...string) { + t.Helper() + var b strings.Builder + for _, id := range ids { + b.WriteString(replayEventJSON(t, id)) + b.WriteByte('\n') + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, modTime, modTime); err != nil { + t.Fatal(err) + } +} + +func replayEventJSON(t *testing.T, id string) string { + t.Helper() + raw := map[string]any{"event_id": id} + b, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/internal/eventlog/v2/writer.go b/internal/eventlog/v2/writer.go new file mode 100644 index 0000000..2b1ffe0 --- /dev/null +++ b/internal/eventlog/v2/writer.go @@ -0,0 +1,132 @@ +// Package eventlogv2 writes schema-2.0 ingest WAL entries as raw JSONL. +package eventlogv2 + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +type Writer struct { + mu sync.Mutex + dir string + syncOnWrite bool + maxFileBytes int64 + f *os.File + activePath string + bytesWritten int64 + seq int +} + +type Option func(*Writer) + +func WithSync(enabled bool) Option { + return func(w *Writer) { w.syncOnWrite = enabled } +} + +func WithMaxBytes(n int64) Option { + return func(w *Writer) { w.maxFileBytes = n } +} + +func New(dir string, opts ...Option) (*Writer, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("eventlogv2: mkdir %s: %w", dir, err) + } + w := &Writer{dir: dir} + for _, opt := range opts { + opt(w) + } + if err := w.openNewFileLocked(); err != nil { + return nil, err + } + return w, nil +} + +func (w *Writer) WriteRaw(line []byte) error { + w.mu.Lock() + defer w.mu.Unlock() + if w.f == nil { + return fmt.Errorf("eventlogv2: writer closed") + } + line = bytes.TrimRight(line, "\r\n") + record := make([]byte, 0, len(line)+1) + record = append(record, line...) + record = append(record, '\n') + written, err := w.f.Write(record) + if err != nil { + return err + } + w.bytesWritten += int64(written) + if w.syncOnWrite { + if err := w.f.Sync(); err != nil { + return err + } + } + if w.maxFileBytes > 0 && w.bytesWritten >= w.maxFileBytes { + if err := w.rotateLocked(); err != nil { + return err + } + } + return nil +} + +func (w *Writer) ActivePath() string { + w.mu.Lock() + defer w.mu.Unlock() + return w.activePath +} + +func (w *Writer) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.f == nil { + return nil + } + f := w.f + w.f = nil + w.activePath = "" + syncErr := f.Sync() + closeErr := f.Close() + if syncErr != nil { + return syncErr + } + return closeErr +} + +func (w *Writer) rotateLocked() error { + if w.f != nil { + _ = w.f.Sync() + if err := w.f.Close(); err != nil { + return err + } + w.f = nil + w.activePath = "" + } + return w.openNewFileLocked() +} + +func (w *Writer) openNewFileLocked() error { + ts := time.Now().UTC().Format("20060102-150405") + for { + name := fmt.Sprintf("events-%s.jsonl", ts) + if w.seq > 0 { + name = fmt.Sprintf("events-%s-%d.jsonl", ts, w.seq) + } + path := filepath.Join(w.dir, name) + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY|os.O_APPEND, 0o644) + if os.IsExist(err) { + w.seq++ + continue + } + if err != nil { + return fmt.Errorf("eventlogv2: open %s: %w", path, err) + } + w.f = f + w.activePath = path + w.bytesWritten = 0 + return nil + } +} diff --git a/internal/eventlog/v2/writer_test.go b/internal/eventlog/v2/writer_test.go new file mode 100644 index 0000000..fa1c979 --- /dev/null +++ b/internal/eventlog/v2/writer_test.go @@ -0,0 +1,88 @@ +package eventlogv2 + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriterWriteRawAppendsNewline(t *testing.T) { + dir := t.TempDir() + w, err := New(dir) + if err != nil { + t.Fatal(err) + } + if err := w.WriteRaw([]byte("foo")); err != nil { + t.Fatal(err) + } + if err := w.WriteRaw([]byte("bar\n")); err != nil { + t.Fatal(err) + } + path := w.ActivePath() + if err := w.Close(); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "foo\nbar\n" { + t.Fatalf("content=%q", string(got)) + } +} + +func TestWriterRotatesAtMaxBytes(t *testing.T) { + dir := t.TempDir() + w, err := New(dir, WithMaxBytes(100)) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 3; i++ { + if err := w.WriteRaw(bytes.Repeat([]byte("a"), 200)); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + entries, err := filepath.Glob(filepath.Join(dir, "events-*.jsonl")) + if err != nil { + t.Fatal(err) + } + if len(entries) < 2 { + t.Fatalf("files=%d want >=2", len(entries)) + } +} + +func TestWriterSyncModesAndCloseIdempotent(t *testing.T) { + for _, syncOnWrite := range []bool{false, true} { + t.Run(strings.ToLower(boolString(syncOnWrite)), func(t *testing.T) { + dir := t.TempDir() + w, err := New(dir, WithSync(syncOnWrite)) + if err != nil { + t.Fatal(err) + } + if err := w.WriteRaw([]byte("foo")); err != nil { + t.Fatal(err) + } + if w.ActivePath() == "" { + t.Fatal("ActivePath empty before close") + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + }) + } +} + +func boolString(v bool) string { + if v { + return "true" + } + return "false" +} diff --git a/internal/ingest/handler.go b/internal/ingest/handler.go index 7d86f79..d73f66a 100644 --- a/internal/ingest/handler.go +++ b/internal/ingest/handler.go @@ -366,44 +366,6 @@ func (s *Server) SetReplayResult(err error) { } } -// Validate handles POST /v1/events/validate — dry-run validation without ingestion. -// TODO(phase2-slice2): rewrite this endpoint for the schema-2.0 envelope once -// WAL-backed v2 ingest is in place. It remains v1-compatible for now. -func (s *Server) Validate(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - r.Body = http.MaxBytesReader(w, r.Body, s.maxBodyBytes) - - var ev event.WideEvent - dec := json.NewDecoder(r.Body) - dec.DisallowUnknownFields() - - if err := dec.Decode(&ev); err != nil { - var maxErr *http.MaxBytesError - if errors.As(err, &maxErr) { - http.Error(w, "request body too large", http.StatusRequestEntityTooLarge) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]any{"valid": false, "errors": []string{err.Error()}}) - return - } - - if err := ev.Validate(); err != nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]any{"valid": false, "errors": []string{err.Error()}}) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"valid": true}) -} - // Store returns the server's graph store. func (s *Server) Store() *store.Store { return s.store diff --git a/internal/ingest/handler_test.go b/internal/ingest/handler_test.go index fc12cbf..483814a 100644 --- a/internal/ingest/handler_test.go +++ b/internal/ingest/handler_test.go @@ -566,38 +566,6 @@ func TestReadEndpoints_NoStore(t *testing.T) { }) } -func TestValidate_ValidEvent(t *testing.T) { - srv := NewServer(ServerConfig{Store: graphstore.NewStore()}) - body := `{"schema_version":"1.0","event_name":"test.request","timestamp":"2026-02-17T10:00:00Z","user":{"id":"u1"},"request":{"trace_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"},"system":{"service":"test","env":"prod"},"outcome":{"success":true,"status_code":200,"kind":"http"},"metrics":{"latency_ms":10}}` - req := httptest.NewRequest(http.MethodPost, "/v1/events/validate", strings.NewReader(body)) - w := httptest.NewRecorder() - srv.Validate(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - var resp map[string]any - json.NewDecoder(w.Body).Decode(&resp) - if resp["valid"] != true { - t.Errorf("expected valid=true, got %v", resp["valid"]) - } -} - -func TestValidate_InvalidEvent(t *testing.T) { - srv := NewServer(ServerConfig{Store: graphstore.NewStore()}) - body := `{"schema_version":"1.0","event_name":"test.request","timestamp":"2026-02-17T10:00:00Z","user":{"id":""},"request":{"trace_id":"aaa"},"system":{"service":"test","env":"prod"},"outcome":{"success":true,"status_code":200,"kind":"http"},"metrics":{"latency_ms":10}}` - req := httptest.NewRequest(http.MethodPost, "/v1/events/validate", strings.NewReader(body)) - w := httptest.NewRecorder() - srv.Validate(w, req) - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", w.Code) - } - var resp map[string]any - json.NewDecoder(w.Body).Decode(&resp) - if resp["valid"] != false { - t.Errorf("expected valid=false, got %v", resp["valid"]) - } -} - func TestEventSearch_NoFilter(t *testing.T) { srv := NewServer(ServerConfig{Store: graphstore.NewStore(), EventLogDir: t.TempDir()}) @@ -669,26 +637,6 @@ func newTestEventLog(dir string) (*eventlog.Writer, error) { return eventlog.New(dir) } -func TestValidate_BadJSON(t *testing.T) { - srv := NewServer(ServerConfig{Store: graphstore.NewStore()}) - req := httptest.NewRequest(http.MethodPost, "/v1/events/validate", strings.NewReader("{bad")) - w := httptest.NewRecorder() - srv.Validate(w, req) - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", w.Code) - } -} - -func TestValidate_MethodNotAllowed(t *testing.T) { - srv := NewServer(ServerConfig{Store: graphstore.NewStore()}) - req := httptest.NewRequest(http.MethodGet, "/v1/events/validate", nil) - w := httptest.NewRecorder() - srv.Validate(w, req) - if w.Code != http.StatusMethodNotAllowed { - t.Fatalf("expected 405, got %d", w.Code) - } -} - func TestEventSearch_BadStartReturns400(t *testing.T) { srv := NewServer(ServerConfig{Store: graphstore.NewStore(), EventLogDir: t.TempDir()}) diff --git a/internal/ingest/pipeline.go b/internal/ingest/pipeline.go index 182f9fe..696e1c5 100644 --- a/internal/ingest/pipeline.go +++ b/internal/ingest/pipeline.go @@ -162,6 +162,9 @@ func (p *Pipeline) IngestBatch(ctx context.Context, events []*event.WideEvent) ( } result.Accepted++ + if p.metrics != nil && p.eventLog != nil { + p.metrics.EventsAccepted.Inc() + } // Windowed counters — post-WAL so WAL failures are never counted. if p.counters != nil { @@ -220,7 +223,6 @@ func (p *Pipeline) IngestBatch(ctx context.Context, events []*event.WideEvent) ( } if p.metrics != nil { p.metrics.MergeLatency.Observe(time.Since(mergeStart).Seconds()) - p.metrics.EventsAccepted.Inc() } } diff --git a/internal/ingest/pipeline_test.go b/internal/ingest/pipeline_test.go index ac08671..7b9398f 100644 --- a/internal/ingest/pipeline_test.go +++ b/internal/ingest/pipeline_test.go @@ -5,8 +5,11 @@ import ( "testing" "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/sssmaran/WaylogCLI/internal/eventlog" "github.com/sssmaran/WaylogCLI/internal/graph/build" "github.com/sssmaran/WaylogCLI/internal/graph/store" + "github.com/sssmaran/WaylogCLI/internal/metrics" "github.com/sssmaran/WaylogCLI/internal/sampler" "github.com/sssmaran/WaylogCLI/pkg/event" ) @@ -175,6 +178,66 @@ func TestIngestBatch_SamplingAccounting(t *testing.T) { } } +func TestIngestBatch_AcceptedMetricCountsDurableSampledOutEvents(t *testing.T) { + reg := prometheus.NewRegistry() + m := metrics.New(reg) + el, err := eventlog.NewWithConfig(t.TempDir(), eventlog.WriterConfig{}) + if err != nil { + t.Fatal(err) + } + defer el.Close() + + p := NewPipeline(PipelineConfig{ + Store: store.NewStore(), + Builder: build.NewBuilder(), + Sampler: sampler.New(sampler.Config{HappySampleRatePct: 1, SlowMs: 10000, Salt: "deterministic"}), + EventLog: el, + Metrics: m, + Validator: func(ev *event.WideEvent) error { + return ev.Validate() + }, + }) + + accepted, sampledOut := 0, 0 + for i := 0; i < 20; i++ { + ev := validSDKEvent() + ev.Request.TraceID = traceIDForIndex(i) + res, err := p.ValidateAndIngestBatch(context.Background(), []*event.WideEvent{ev}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + accepted += res.Accepted + sampledOut += res.SampledOut + } + if sampledOut == 0 { + t.Fatal("test did not exercise sampled-out durable events") + } + if got := counterMetric(t, reg, "waylog_events_accepted_total"); got != float64(accepted) { + t.Fatalf("events_accepted=%v want %d", got, accepted) + } +} + +func counterMetric(t *testing.T, reg *prometheus.Registry, name string) float64 { + t.Helper() + families, err := reg.Gather() + if err != nil { + t.Fatal(err) + } + for _, mf := range families { + if mf.GetName() != name { + continue + } + var total float64 + for _, metric := range mf.GetMetric() { + if counter := metric.GetCounter(); counter != nil { + total += counter.GetValue() + } + } + return total + } + return 0 +} + // traceIDForIndex generates a distinct 32-hex trace id for each index. func traceIDForIndex(i int) string { hex := "0123456789abcdef" diff --git a/internal/ingest/v2/dedup.go b/internal/ingest/v2/dedup.go new file mode 100644 index 0000000..a9f7b03 --- /dev/null +++ b/internal/ingest/v2/dedup.go @@ -0,0 +1,114 @@ +package ingestv2 + +import ( + "container/list" + "sync" +) + +const DefaultDedupCapacity = 65536 + +type dedupEntry struct { + eventID string +} + +// Dedup is an exact recent event_id LRU. It tracks newest-N inserts; Seen +// deliberately does not promote reads so replay order matches runtime order. +type Dedup struct { + mu sync.Mutex + capacity int + items map[string]*list.Element + order *list.List + size interface{ Set(float64) } +} + +// NewDedup creates an event_id LRU with a bounded capacity. +func NewDedup(capacity int, sizeGauge interface{ Set(float64) }) *Dedup { + if capacity <= 0 { + capacity = DefaultDedupCapacity + } + d := &Dedup{ + capacity: capacity, + items: make(map[string]*list.Element, capacity), + order: list.New(), + size: sizeGauge, + } + d.observeSizeLocked() + return d +} + +// Seen reports whether eventID is in the recent-ID cache. +func (d *Dedup) Seen(eventID string) bool { + if d == nil || eventID == "" { + return false + } + d.mu.Lock() + defer d.mu.Unlock() + _, ok := d.items[eventID] + return ok +} + +// Add records eventID as the newest durable event and evicts oldest entries +// until the cache is within capacity. +func (d *Dedup) Add(eventID string) { + if d == nil || eventID == "" { + return + } + d.mu.Lock() + defer d.mu.Unlock() + d.addLocked(eventID) +} + +// AddIfNew runs commit while holding the event-id cache lock, then records the +// ID only if commit succeeds. It returns true when the ID was already present. +func (d *Dedup) AddIfNew(eventID string, commit func() error) (bool, error) { + if d == nil || eventID == "" { + return false, commit() + } + d.mu.Lock() + defer d.mu.Unlock() + + if _, ok := d.items[eventID]; ok { + return true, nil + } + if err := commit(); err != nil { + return false, err + } + d.addLocked(eventID) + return false, nil +} + +func (d *Dedup) addLocked(eventID string) { + if elem, ok := d.items[eventID]; ok { + d.order.MoveToFront(elem) + d.observeSizeLocked() + return + } + elem := d.order.PushFront(dedupEntry{eventID: eventID}) + d.items[eventID] = elem + for d.order.Len() > d.capacity { + oldest := d.order.Back() + if oldest == nil { + break + } + entry := oldest.Value.(dedupEntry) + delete(d.items, entry.eventID) + d.order.Remove(oldest) + } + d.observeSizeLocked() +} + +// Size returns the current number of tracked event IDs. +func (d *Dedup) Size() int { + if d == nil { + return 0 + } + d.mu.Lock() + defer d.mu.Unlock() + return d.order.Len() +} + +func (d *Dedup) observeSizeLocked() { + if d.size != nil { + d.size.Set(float64(d.order.Len())) + } +} diff --git a/internal/ingest/v2/dedup_test.go b/internal/ingest/v2/dedup_test.go new file mode 100644 index 0000000..6899482 --- /dev/null +++ b/internal/ingest/v2/dedup_test.go @@ -0,0 +1,80 @@ +package ingestv2 + +import ( + "fmt" + "sync" + "testing" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" +) + +func TestDedupEvictsOldestAtCapacity(t *testing.T) { + d := NewDedup(1024, nil) + for i := 0; i < 2048; i++ { + d.Add(fmt.Sprintf("event-%04d", i)) + } + if got := d.Size(); got != 1024 { + t.Fatalf("Size=%d want 1024", got) + } + for i := 0; i < 1024; i++ { + if d.Seen(fmt.Sprintf("event-%04d", i)) { + t.Fatalf("old event %d should be evicted", i) + } + } + for i := 1024; i < 2048; i++ { + if !d.Seen(fmt.Sprintf("event-%04d", i)) { + t.Fatalf("new event %d should be present", i) + } + } +} + +func TestDedupSeenDoesNotPromote(t *testing.T) { + d := NewDedup(3, nil) + d.Add("a") + d.Add("b") + d.Add("c") + if !d.Seen("a") { + t.Fatal("a should be present before eviction") + } + d.Add("d") + if d.Seen("a") { + t.Fatal("Seen should not promote a; it should be evicted") + } +} + +func TestDedupConcurrent(t *testing.T) { + d := NewDedup(256, nil) + var wg sync.WaitGroup + for g := 0; g < 64; g++ { + wg.Add(1) + go func(g int) { + defer wg.Done() + for i := 0; i < 1000; i++ { + id := fmt.Sprintf("g-%02d-%04d", g, i) + d.Add(id) + _ = d.Seen(id) + } + }(g) + } + wg.Wait() + if got := d.Size(); got > 256 { + t.Fatalf("Size=%d want <=256", got) + } +} + +func TestDedupGaugeTracksSize(t *testing.T) { + g := prometheus.NewGauge(prometheus.GaugeOpts{Name: "test_event_dedup_size", Help: "test"}) + d := NewDedup(2, g) + d.Add("a") + d.Add("b") + d.Add("c") + + var metric dto.Metric + if err := g.Write(&metric); err != nil { + t.Fatal(err) + } + if got := metric.GetGauge().GetValue(); got != 2 { + t.Fatalf("gauge=%v want 2", got) + } +} diff --git a/internal/ingest/v2/envelope.go b/internal/ingest/v2/envelope.go index f5d11f3..6067da0 100644 --- a/internal/ingest/v2/envelope.go +++ b/internal/ingest/v2/envelope.go @@ -35,6 +35,7 @@ const ( ReasonUnsupportedContentType = "unsupported_content_type" ReasonUnsupportedEncoding = "unsupported_content_encoding" ReasonInvalidBody = "invalid_body" + ReasonDurabilityUnavailable = "durability_unavailable" ) // newEnvelope returns a fresh response envelope with Rejected and diff --git a/internal/ingest/v2/handler.go b/internal/ingest/v2/handler.go index 73a953f..11b79c4 100644 --- a/internal/ingest/v2/handler.go +++ b/internal/ingest/v2/handler.go @@ -15,22 +15,43 @@ import ( type Handler struct { schema *jsonschema.Schema metrics *metrics.Metrics + dedup *Dedup + wal WAL +} + +type WAL interface { + WriteRaw([]byte) error +} + +type Config struct { + Metrics *metrics.Metrics + Dedup *Dedup + WAL WAL } // New compiles the embedded v2.0 schema once for request-time reuse and // pre-initializes the rejection-reason label series this handler emits so // dashboards see zero-valued series before the first hit. -func New(m *metrics.Metrics) (*Handler, error) { +func New(cfg Config) (*Handler, error) { sch, err := eventv2.CompileEmbeddedSchema() if err != nil { return nil, err } + m := cfg.Metrics if m != nil { for _, reason := range rejectionReasons { m.EventsRejected.WithLabelValues(reason).Add(0) } } - return &Handler{schema: sch, metrics: m}, nil + dedup := cfg.Dedup + if dedup == nil { + var sizeGauge interface{ Set(float64) } + if m != nil { + sizeGauge = m.EventDedupCacheSize + } + dedup = NewDedup(DefaultDedupCapacity, sizeGauge) + } + return &Handler{schema: sch, metrics: m, dedup: dedup, wal: cfg.WAL}, nil } var rejectionReasons = []string{ @@ -42,11 +63,22 @@ var rejectionReasons = []string{ ReasonUnsupportedContentType, ReasonUnsupportedEncoding, ReasonInvalidBody, + ReasonDurabilityUnavailable, } // Events validates schema-2.0 JSON/NDJSON ingest requests and returns the -// §5.1.2 envelope. Slice 1 validates-and-discards; durability lands in Slice 2. +// §5.1.2 envelope. Accepted events are written to the v2 WAL before response. func (h *Handler) Events(w http.ResponseWriter, r *http.Request) { + h.handle(w, r, true) +} + +// Validate dry-runs schema-2.0 ingest using the same parser and envelope as +// Events, but without dedupe or WAL persistence. +func (h *Handler) Validate(w http.ResponseWriter, r *http.Request) { + h.handle(w, r, false) +} + +func (h *Handler) handle(w http.ResponseWriter, r *http.Request, durable bool) { start := time.Now() if h.metrics != nil { h.metrics.InFlightRequests.Inc() @@ -71,31 +103,70 @@ func (h *Handler) Events(w http.ResponseWriter, r *http.Request) { env := newEnvelope() for i, eventBody := range parsed.events { - rej, ok := h.validateEvent(i, eventBody) - if ok { + result := h.validateEvent(i, eventBody) + if !result.ok { + env.Rejected = append(env.Rejected, result.rejected) + h.recordRejected(result.rejected.Reason) + continue + } + if !durable { env.Accepted++ continue } - env.Rejected = append(env.Rejected, rej) - h.recordRejected(rej.Reason) + if h.wal == nil { + h.recordRejected(ReasonDurabilityUnavailable) + http.Error(w, "durability unavailable", http.StatusServiceUnavailable) + return + } + var duplicate bool + var err error + if h.dedup == nil { + err = h.wal.WriteRaw(eventBody) + } else { + duplicate, err = h.dedup.AddIfNew(result.eventID, func() error { + return h.wal.WriteRaw(eventBody) + }) + } + if err != nil { + h.recordRejected(ReasonDurabilityUnavailable) + http.Error(w, "durability unavailable", http.StatusServiceUnavailable) + return + } + if duplicate { + env.Duplicate++ + if h.metrics != nil { + h.metrics.EventsDuplicate.Inc() + } + continue + } + if h.metrics != nil { + h.metrics.EventsAccepted.Inc() + } + env.Accepted++ } writeEnvelope(w, http.StatusOK, env) } -func (h *Handler) validateEvent(index int, eventBody []byte) (RejectedEvent, bool) { +type validationResult struct { + ok bool + eventID string + rejected RejectedEvent +} + +func (h *Handler) validateEvent(index int, eventBody []byte) validationResult { var raw any if err := json.Unmarshal(eventBody, &raw); err != nil { - return RejectedEvent{Index: index, Reason: ReasonInvalidJSON, Detail: err.Error()}, false + return validationResult{rejected: RejectedEvent{Index: index, Reason: ReasonInvalidJSON, Detail: err.Error()}} } eventID, schemaVersion := rawIdentifiers(raw) if strings.HasPrefix(schemaVersion, "1.") { - return RejectedEvent{Index: index, EventID: eventID, Reason: ReasonBridgeNotImplemented, Detail: "v1.x bridge ships in Slice 4"}, false + return validationResult{eventID: eventID, rejected: RejectedEvent{Index: index, EventID: eventID, Reason: ReasonBridgeNotImplemented, Detail: "v1.x bridge ships in Slice 4"}} } if err := eventv2.ValidateAny(h.schema, raw); err != nil { - return RejectedEvent{Index: index, EventID: eventID, Reason: ReasonSchemaValidationFailed, Detail: err.Error()}, false + return validationResult{eventID: eventID, rejected: RejectedEvent{Index: index, EventID: eventID, Reason: ReasonSchemaValidationFailed, Detail: err.Error()}} } - return RejectedEvent{}, true + return validationResult{ok: true, eventID: eventID} } func rawIdentifiers(raw any) (eventID, schemaVersion string) { diff --git a/internal/ingest/v2/handler_test.go b/internal/ingest/v2/handler_test.go index baaf239..b5b59a5 100644 --- a/internal/ingest/v2/handler_test.go +++ b/internal/ingest/v2/handler_test.go @@ -9,7 +9,9 @@ import ( "net/http/httptest" "strings" "sync" + "sync/atomic" "testing" + "time" "github.com/prometheus/client_golang/prometheus" dto "github.com/prometheus/client_model/go" @@ -18,7 +20,7 @@ import ( ) func TestEventsSingleJSONValid(t *testing.T) { - h := newTestHandler(t, nil) + h, wal := newTestHandler(t, nil) rec := post(t, h, "application/json", "", validEventJSON("00000000-0000-4000-8000-000000000001")) if rec.Code != http.StatusOK { @@ -39,13 +41,17 @@ func TestEventsSingleJSONValid(t *testing.T) { if string(wire["deprecations"]) != "{}" { t.Fatalf("deprecations wire=%s want {}", wire["deprecations"]) } + if got := wal.Count(); got != 1 { + t.Fatalf("wal writes=%d want 1", got) + } } func TestEventsRejectsV1BridgeNotImplemented(t *testing.T) { raw := validEventMap("00000000-0000-4000-8000-000000000001") raw["schema_version"] = "1.1" - rec := post(t, newTestHandler(t, nil), "application/json", "", mustJSON(t, raw)) + h, _ := newTestHandler(t, nil) + rec := post(t, h, "application/json", "", mustJSON(t, raw)) env := expectEnvelopeStatus(t, rec, http.StatusOK) if env.Accepted != 0 || len(env.Rejected) != 1 || env.Rejected[0].Reason != ReasonBridgeNotImplemented { t.Fatalf("env=%+v", env) @@ -55,7 +61,8 @@ func TestEventsRejectsV1BridgeNotImplemented(t *testing.T) { func TestEventsUnsupportedContentEncoding(t *testing.T) { for _, encoding := range []string{"br", "deflate", "compress"} { t.Run(encoding, func(t *testing.T) { - rec := post(t, newTestHandler(t, nil), "application/json", encoding, validEventJSON("00000000-0000-4000-8000-000000000001")) + h, _ := newTestHandler(t, nil) + rec := post(t, h, "application/json", encoding, validEventJSON("00000000-0000-4000-8000-000000000001")) env := expectEnvelopeStatus(t, rec, http.StatusBadRequest) if env.Rejected[0].Reason != ReasonUnsupportedEncoding { t.Fatalf("reason=%q", env.Rejected[0].Reason) @@ -66,20 +73,23 @@ func TestEventsUnsupportedContentEncoding(t *testing.T) { func TestEventsBodySizeBoundaries(t *testing.T) { exact := exactSizedJSON(t, maxBodyBytes) - rec := post(t, newTestHandler(t, nil), "application/json", "", exact) + h, _ := newTestHandler(t, nil) + rec := post(t, h, "application/json", "", exact) env := expectEnvelopeStatus(t, rec, http.StatusOK) if env.Accepted != 1 { t.Fatalf("accepted=%d body=%s", env.Accepted, rec.Body.String()) } tooLarge := exactSizedJSON(t, maxBodyBytes+1) - rec = post(t, newTestHandler(t, nil), "application/json", "", tooLarge) + h, _ = newTestHandler(t, nil) + rec = post(t, h, "application/json", "", tooLarge) env = expectEnvelopeStatus(t, rec, http.StatusRequestEntityTooLarge) if env.Rejected[0].Reason != ReasonBodyOversize { t.Fatalf("reason=%q", env.Rejected[0].Reason) } - rec = postBytes(t, newTestHandler(t, nil), "application/json", "gzip", gzipBytes([]byte(tooLarge))) + h, _ = newTestHandler(t, nil) + rec = postBytes(t, h, "application/json", "gzip", gzipBytes([]byte(tooLarge))) env = expectEnvelopeStatus(t, rec, http.StatusRequestEntityTooLarge) if env.Rejected[0].Reason != ReasonBodyOversize { t.Fatalf("reason=%q", env.Rejected[0].Reason) @@ -91,7 +101,8 @@ func TestEventsSchemaValidationFailures(t *testing.T) { t.Run(field, func(t *testing.T) { raw := validEventMap("00000000-0000-4000-8000-000000000001") delete(raw, field) - rec := post(t, newTestHandler(t, nil), "application/json", "", mustJSON(t, raw)) + h, _ := newTestHandler(t, nil) + rec := post(t, h, "application/json", "", mustJSON(t, raw)) env := expectEnvelopeStatus(t, rec, http.StatusOK) if env.Accepted != 0 || len(env.Rejected) != 1 || env.Rejected[0].Reason != ReasonSchemaValidationFailed { t.Fatalf("env=%+v", env) @@ -104,7 +115,7 @@ func TestEventsSchemaValidationFailures(t *testing.T) { } func TestEventsNDJSONBatches(t *testing.T) { - h := newTestHandler(t, nil) + h, _ := newTestHandler(t, nil) body := strings.Join([]string{ validEventJSON("00000000-0000-4000-8000-000000000001"), validEventJSON("00000000-0000-4000-8000-000000000002"), @@ -126,7 +137,7 @@ func TestEventsNDJSONBatches(t *testing.T) { } env = expectEnvelopeStatus(t, postBytes(t, h, "application/x-ndjson", "gzip", gzipBytes([]byte(body))), http.StatusOK) - if env.Accepted != 3 { + if env.Accepted != 0 || env.Duplicate != 3 { t.Fatalf("env=%+v", env) } } @@ -137,14 +148,16 @@ func TestEventsBatchOversize(t *testing.T) { b.WriteString(validEventJSON(fmt.Sprintf("00000000-0000-4000-8000-%012d", i+1))) b.WriteByte('\n') } - env := expectEnvelopeStatus(t, post(t, newTestHandler(t, nil), "application/x-ndjson", "", b.String()), http.StatusRequestEntityTooLarge) + h, _ := newTestHandler(t, nil) + env := expectEnvelopeStatus(t, post(t, h, "application/x-ndjson", "", b.String()), http.StatusRequestEntityTooLarge) if env.Rejected[0].Reason != ReasonBatchOversize { t.Fatalf("reason=%q", env.Rejected[0].Reason) } } func TestEventsGzipInvalidBody(t *testing.T) { - rec := post(t, newTestHandler(t, nil), "application/x-ndjson", "gzip", "not gzip") + h, _ := newTestHandler(t, nil) + rec := post(t, h, "application/x-ndjson", "gzip", "not gzip") env := expectEnvelopeStatus(t, rec, http.StatusBadRequest) if env.Rejected[0].Reason != ReasonInvalidBody { t.Fatalf("reason=%q", env.Rejected[0].Reason) @@ -152,7 +165,7 @@ func TestEventsGzipInvalidBody(t *testing.T) { } func TestEventsContentTypeHandling(t *testing.T) { - h := newTestHandler(t, nil) + h, _ := newTestHandler(t, nil) expectEnvelopeStatus(t, post(t, h, "application/json; charset=utf-8", "", validEventJSON("00000000-0000-4000-8000-000000000001")), http.StatusOK) ndjson := validEventJSON("00000000-0000-4000-8000-000000000002") + "\n" @@ -172,7 +185,7 @@ func TestEventsContentTypeHandling(t *testing.T) { } func TestEventsAuthAndMethod(t *testing.T) { - h := newTestHandler(t, nil) + h, _ := newTestHandler(t, nil) protected := auth.Middleware("write", []string{"test"}, nil)(http.HandlerFunc(h.Events)) req := httptest.NewRequest(http.MethodPost, "/v1/events", strings.NewReader(validEventJSON("00000000-0000-4000-8000-000000000001"))) @@ -192,7 +205,7 @@ func TestEventsAuthAndMethod(t *testing.T) { } func TestEventsConcurrentSchemaReuse(t *testing.T) { - h := newTestHandler(t, nil) + h, _ := newTestHandler(t, nil) body := validEventJSON("00000000-0000-4000-8000-000000000001") var wg sync.WaitGroup errs := make(chan string, 64) @@ -216,14 +229,16 @@ func TestEventsConcurrentSchemaReuse(t *testing.T) { func TestEventsMetrics(t *testing.T) { reg := prometheus.NewRegistry() m := metrics.New(reg) - h := newTestHandler(t, m) + h, _ := newTestHandler(t, m) + offset := 0 for _, n := range []int{1, 16, 256} { var b strings.Builder for i := 0; i < n; i++ { - b.WriteString(validEventJSON(fmt.Sprintf("00000000-0000-4000-8000-%012d", i+1))) + b.WriteString(validEventJSON(fmt.Sprintf("00000000-0000-4000-8000-%012d", offset+i+1))) b.WriteByte('\n') } + offset += n expectEnvelopeStatus(t, post(t, h, "application/x-ndjson", "", b.String()), http.StatusOK) } @@ -242,11 +257,165 @@ func TestEventsMetrics(t *testing.T) { if got := counterWithLabel(fm["waylog_events_rejected_total"], "reason", ReasonSchemaValidationFailed); got != 1 { t.Fatalf("schema_validation_failed=%v want 1", got) } + if got := counterValue(fm["waylog_events_accepted_total"]); got != 273 { + t.Fatalf("events_accepted=%v want 273", got) + } +} + +func TestEventsDedupeWritesOnlyOnce(t *testing.T) { + reg := prometheus.NewRegistry() + m := metrics.New(reg) + h, wal := newTestHandler(t, m) + body := validEventJSON("00000000-0000-4000-8000-000000000001") + + env := expectEnvelopeStatus(t, post(t, h, "application/json", "", body), http.StatusOK) + if env.Accepted != 1 || env.Duplicate != 0 { + t.Fatalf("first env=%+v", env) + } + env = expectEnvelopeStatus(t, post(t, h, "application/json", "", body), http.StatusOK) + if env.Accepted != 0 || env.Duplicate != 1 { + t.Fatalf("second env=%+v", env) + } + if got := wal.Count(); got != 1 { + t.Fatalf("wal writes=%d want 1", got) + } + fm := gatherMap(t, reg) + if got := counterValue(fm["waylog_events_duplicate_total"]); got != 1 { + t.Fatalf("events_duplicate=%v want 1", got) + } +} + +func TestEventsConcurrentDuplicateWritesOnce(t *testing.T) { + wal := &fakeWAL{delay: 20 * time.Millisecond} + h := newTestHandlerWithConfig(t, Config{ + Dedup: NewDedup(DefaultDedupCapacity, nil), + WAL: wal, + }) + body := validEventJSON("00000000-0000-4000-8000-000000000001") + + var wg sync.WaitGroup + envs := make(chan IngestEnvelope, 32) + errs := make(chan string, 32) + for i := 0; i < 32; i++ { + wg.Add(1) + go func() { + defer wg.Done() + rec := post(t, h, "application/json", "", body) + if rec.Code != http.StatusOK { + errs <- fmt.Sprintf("status=%d body=%s", rec.Code, rec.Body.String()) + return + } + var env IngestEnvelope + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + errs <- fmt.Sprintf("decode envelope: %v body=%s", err, rec.Body.String()) + return + } + envs <- env + }() + } + wg.Wait() + close(envs) + close(errs) + for err := range errs { + t.Fatal(err) + } + + accepted, duplicate := 0, 0 + for env := range envs { + accepted += env.Accepted + duplicate += env.Duplicate + } + if accepted != 1 || duplicate != 31 { + t.Fatalf("accepted=%d duplicate=%d want 1/31", accepted, duplicate) + } + if got := wal.Count(); got != 1 { + t.Fatalf("wal writes=%d want 1", got) + } +} + +func TestEventsWALFailureReturnsPlain503(t *testing.T) { + reg := prometheus.NewRegistry() + m := metrics.New(reg) + wal := &fakeWAL{failAt: 1} + h := newTestHandlerWithWAL(t, m, wal) + + rec := post(t, h, "application/json", "", validEventJSON("00000000-0000-4000-8000-000000000001")) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Header().Get("Content-Type"), "application/json") { + t.Fatalf("503 should not return JSON content type: %q", rec.Header().Get("Content-Type")) + } + if !strings.Contains(rec.Body.String(), "durability unavailable") { + t.Fatalf("body=%q", rec.Body.String()) + } + fm := gatherMap(t, reg) + if got := counterWithLabel(fm["waylog_events_rejected_total"], "reason", ReasonDurabilityUnavailable); got != 1 { + t.Fatalf("durability_unavailable=%v want 1", got) + } + if got := counterValue(fm["waylog_events_accepted_total"]); got != 0 { + t.Fatalf("events_accepted=%v want 0", got) + } +} + +func TestEventsWALFailureMidBatchLeavesPriorEventDurableAndDeduped(t *testing.T) { + wal := &fakeWAL{failAt: 2} + dedup := NewDedup(10, nil) + h := newTestHandlerWithConfig(t, Config{Dedup: dedup, WAL: wal}) + body := strings.Join([]string{ + validEventJSON("00000000-0000-4000-8000-000000000001"), + validEventJSON("00000000-0000-4000-8000-000000000002"), + validEventJSON("00000000-0000-4000-8000-000000000003"), + }, "\n") + + rec := post(t, h, "application/x-ndjson", "", body) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if wal.Count() != 1 { + t.Fatalf("wal writes=%d want 1", wal.Count()) + } + if !dedup.Seen("00000000-0000-4000-8000-000000000001") { + t.Fatal("first event should be deduped after successful WAL write") + } +} + +func TestValidateDoesNotMutateWALOrDedup(t *testing.T) { + dedup := NewDedup(10, nil) + wal := &fakeWAL{} + h := newTestHandlerWithConfig(t, Config{Dedup: dedup, WAL: wal}) + raw := validEventMap("00000000-0000-4000-8000-000000000002") + delete(raw, "service") + body := validEventJSON("00000000-0000-4000-8000-000000000001") + "\n" + mustJSON(t, raw) + + rec := validatePost(t, h, "application/x-ndjson", "", body) + env := expectEnvelopeStatus(t, rec, http.StatusOK) + if env.Accepted != 1 || env.Duplicate != 0 || len(env.Rejected) != 1 { + t.Fatalf("env=%+v", env) + } + if wal.Count() != 0 { + t.Fatalf("wal writes=%d want 0", wal.Count()) + } + if dedup.Size() != 0 { + t.Fatalf("dedup size=%d want 0", dedup.Size()) + } } -func newTestHandler(t *testing.T, m *metrics.Metrics) *Handler { +func newTestHandler(t *testing.T, m *metrics.Metrics) (*Handler, *fakeWAL) { t.Helper() - h, err := New(m) + wal := &fakeWAL{} + h := newTestHandlerWithConfig(t, Config{Metrics: m, Dedup: NewDedup(DefaultDedupCapacity, nil), WAL: wal}) + return h, wal +} + +func newTestHandlerWithWAL(t *testing.T, m *metrics.Metrics, wal WAL) *Handler { + t.Helper() + return newTestHandlerWithConfig(t, Config{Metrics: m, Dedup: NewDedup(DefaultDedupCapacity, nil), WAL: wal}) +} + +func newTestHandlerWithConfig(t *testing.T, cfg Config) *Handler { + t.Helper() + h, err := New(cfg) if err != nil { t.Fatalf("New: %v", err) } @@ -272,6 +441,20 @@ func postBytes(t *testing.T, h *Handler, contentType, encoding string, body []by return rec } +func validatePost(t *testing.T, h *Handler, contentType, encoding, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/v1/events/validate", strings.NewReader(body)) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if encoding != "" { + req.Header.Set("Content-Encoding", encoding) + } + rec := httptest.NewRecorder() + h.Validate(rec, req) + return rec +} + func expectEnvelopeStatus(t *testing.T, rec *httptest.ResponseRecorder, status int) IngestEnvelope { t.Helper() if rec.Code != status { @@ -389,3 +572,45 @@ func counterWithLabel(mf *dto.MetricFamily, label, value string) float64 { } return 0 } + +func counterValue(mf *dto.MetricFamily) float64 { + if mf == nil || len(mf.GetMetric()) == 0 || mf.GetMetric()[0].GetCounter() == nil { + return 0 + } + return mf.GetMetric()[0].GetCounter().GetValue() +} + +type fakeWAL struct { + writes [][]byte + failAt int64 + delay time.Duration + count atomic.Int64 + mu sync.Mutex +} + +func (w *fakeWAL) WriteRaw(line []byte) error { + if w.delay > 0 { + time.Sleep(w.delay) + } + n := w.count.Add(1) + if w.failAt > 0 && n == w.failAt { + return errFakeWAL + } + w.mu.Lock() + defer w.mu.Unlock() + cp := append([]byte(nil), line...) + w.writes = append(w.writes, cp) + return nil +} + +func (w *fakeWAL) Count() int { + w.mu.Lock() + defer w.mu.Unlock() + return len(w.writes) +} + +var errFakeWAL = &fakeWALError{} + +type fakeWALError struct{} + +func (e *fakeWALError) Error() string { return "fake WAL failure" } diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index e0fff00..f0bcd30 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -13,12 +13,15 @@ var defaultBuckets = []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, type Metrics struct { reg *prometheus.Registry - IngestLatency prometheus.Histogram - IngestBatchSize prometheus.Histogram - MergeLatency prometheus.Histogram - EventsAccepted prometheus.Counter - EventsRejected *prometheus.CounterVec - EventlogFails prometheus.Counter + IngestLatency prometheus.Histogram + IngestBatchSize prometheus.Histogram + MergeLatency prometheus.Histogram + EventsAccepted prometheus.Counter + EventsDuplicate prometheus.Counter + EventsRejected *prometheus.CounterVec + EventlogFails prometheus.Counter + EventDedupCacheSize prometheus.Gauge + EventDedupReplayLoaded prometheus.Counter ReplayLagSeconds prometheus.Gauge ReplayInProgress prometheus.Gauge @@ -89,7 +92,11 @@ func New(reg *prometheus.Registry) *Metrics { }) m.EventsAccepted = prometheus.NewCounter(prometheus.CounterOpts{ Name: "waylog_events_accepted_total", - Help: "Events merged into graph.", + Help: "Events durably written to WAL.", + }) + m.EventsDuplicate = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "waylog_events_duplicate_total", + Help: "Schema-2.0 ingest events skipped because event_id was already recently durably written.", }) m.EventsRejected = prometheus.NewCounterVec(prometheus.CounterOpts{ Name: "waylog_events_rejected_total", @@ -102,6 +109,14 @@ func New(reg *prometheus.Registry) *Metrics { Name: "waylog_eventlog_write_failures_total", Help: "Failed eventlog writes.", }) + m.EventDedupCacheSize = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "waylog_event_dedup_cache_size", + Help: "Current schema-2.0 event_id dedup cache size.", + }) + m.EventDedupReplayLoaded = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "waylog_event_dedup_replay_loaded_total", + Help: "Schema-2.0 event IDs loaded into dedup cache during WAL replay.", + }) m.ReplayLagSeconds = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "waylog_replay_lag_seconds", @@ -280,7 +295,8 @@ func New(reg *prometheus.Registry) *Metrics { reg.MustRegister( m.IngestLatency, m.IngestBatchSize, m.MergeLatency, - m.EventsAccepted, m.EventsRejected, m.EventlogFails, + m.EventsAccepted, m.EventsDuplicate, m.EventsRejected, m.EventlogFails, + m.EventDedupCacheSize, m.EventDedupReplayLoaded, m.ReplayLagSeconds, m.ReplayInProgress, m.ReplayFailuresTotal, m.Ready, m.InFlightRequests, m.SnapshotLastSuccess, m.SnapshotLastError, From 4d790e9dac40d69d6a943a73996a3763fb9ed1d7 Mon Sep 17 00:00:00 2001 From: skota-hash Date: Tue, 28 Apr 2026 12:53:07 -0400 Subject: [PATCH 03/12] feat(ingest): project v2 events into recent index - added schema-2.0 RecentIndex for event, trace, service, error-family, and call aggregates - added v2 Projector that derives only from structured event fields - index suppressed events by id and trace without error or call aggregates - derived error families from service, anchor.step, and anchor.error_code triplets - derive calls only from steps[].downstream.service, never from step names - update /v1/events accepted semantics to require WAL write plus projection - typed-decode events after raw schema validation and before WAL/projection - return schema_validation_failed with decode_typed detail on typed decode drift - recover projection panics, increment panic metric, roll back dedupe, and return plain 503 - warn if projector sees a duplicate event_id after dedupe - keep WAL lines intact on projection failure so replay can repair after restart - refactor eventlog/v2 replay into a raw JSONL iterator without ingest imports - add ingestv2 ReplayWAL to validate, typed-decode, warm dedupe, and project from WAL - bound startup replay by GRAPH_HOT_WINDOW with file-level and event-level filtering - add replay skip metrics by reason for malformed, schema-invalid, typed-decode, and stale lines - wire v2 recent index replay before readiness and prune it on the existing retention tick - add metrics for v2 projection, index size, replay projection, replay skips, typed decode failures, projection panics, and per-stage latency - add tests for projection rules, dedupe rollback, replay rebuild, replay skips, pruning, and trace-order preservation --- cmd/ingest/main.go | 21 +- internal/eventlog/v2/replay.go | 34 +-- internal/eventlog/v2/replay_test.go | 109 ++++----- internal/ingest/v2/dedup.go | 17 ++ internal/ingest/v2/dedup_test.go | 15 ++ internal/ingest/v2/handler.go | 77 ++++++- internal/ingest/v2/handler_test.go | 79 ++++++- internal/ingest/v2/index.go | 320 +++++++++++++++++++++++++++ internal/ingest/v2/projector.go | 28 +++ internal/ingest/v2/projector_test.go | 150 +++++++++++++ internal/ingest/v2/replay.go | 73 ++++++ internal/ingest/v2/replay_test.go | 131 +++++++++++ internal/metrics/metrics.go | 67 +++++- 13 files changed, 1033 insertions(+), 88 deletions(-) create mode 100644 internal/ingest/v2/index.go create mode 100644 internal/ingest/v2/projector.go create mode 100644 internal/ingest/v2/projector_test.go create mode 100644 internal/ingest/v2/replay.go create mode 100644 internal/ingest/v2/replay_test.go diff --git a/cmd/ingest/main.go b/cmd/ingest/main.go index fe363a1..c9997aa 100644 --- a/cmd/ingest/main.go +++ b/cmd/ingest/main.go @@ -162,19 +162,26 @@ func main() { dedupCapacity := config.GetenvInt("WAYLOG_V2_DEDUP_CAPACITY", ingestv2.DefaultDedupCapacity) v2Dedup := ingestv2.NewDedup(dedupCapacity, m.EventDedupCacheSize) - loaded, err := eventlogv2.WarmDedup(eventLogV2Dir, v2Dedup) + v2Index := ingestv2.NewRecentIndex(m.V2IndexSize) + v2Projector := ingestv2.NewProjector(v2Index) + v2ReplaySince := time.Now().Add(-graphHotWindow) + v2Replay, err := ingestv2.ReplayWAL(eventLogV2Dir, v2Dedup, v2Projector, v2ReplaySince, m) if err != nil { - slog.Error("eventlog v2 dedup replay failed", "err", err) + slog.Error("eventlog v2 replay failed", "err", err) os.Exit(1) } - m.EventDedupReplayLoaded.Add(float64(loaded)) + m.EventDedupReplayLoaded.Add(float64(v2Replay.DedupLoaded)) + m.V2ReplayProjected.Add(float64(v2Replay.Projected)) slog.Info("eventlog v2 enabled", "dir", eventLogV2Dir, "sync_per_write", eventLogSync, "max_file_mb", eventLogMaxMB, "retention", eventLogRetention, "dedup_capacity", dedupCapacity, - "dedup_replay_loaded", loaded, + "replay_since", v2ReplaySince, + "dedup_replay_loaded", v2Replay.DedupLoaded, + "replay_projected", v2Replay.Projected, + "replay_decode_fails", v2Replay.DecodeFails, ) // Optional SQLite cold store @@ -337,6 +344,8 @@ func main() { Metrics: m, Dedup: v2Dedup, WAL: v2Wal, + Index: v2Index, + Project: v2Projector, }) if err != nil { slog.Error("initialize v2 ingest handler", "err", err) @@ -492,6 +501,10 @@ func main() { // Enforce retention: prune nodes older than the retention window. cutoff := time.Now().Add(-graphHotWindow) graphStore.PruneOlderThan(cutoff) + v2Pruned := v2Index.PruneOlderThan(cutoff) + if v2Pruned.Events > 0 { + m.V2IndexPruned.Add(float64(v2Pruned.Events)) + } deletedTraces, _ := traceStore.PruneOlderThan(cutoff) m.GraphPrunedTotal.Inc() if deletedTraces > 0 { diff --git a/internal/eventlog/v2/replay.go b/internal/eventlog/v2/replay.go index 404fc96..c0a9fbb 100644 --- a/internal/eventlog/v2/replay.go +++ b/internal/eventlog/v2/replay.go @@ -3,7 +3,6 @@ package eventlogv2 import ( "bufio" "bytes" - "encoding/json" "io" "log/slog" "os" @@ -11,13 +10,11 @@ import ( "sort" "strings" "time" - - ingestv2 "github.com/sssmaran/WaylogCLI/internal/ingest/v2" ) const maxReplayLineBytes = (1 << 20) + 1 -func WarmDedup(dir string, d *ingestv2.Dedup) (int, error) { +func Replay(dir string, since time.Time, fn func(rawLine []byte) error) (int, error) { entries, err := os.ReadDir(dir) if os.IsNotExist(err) { return 0, nil @@ -35,6 +32,9 @@ func WarmDedup(dir string, d *ingestv2.Dedup) (int, error) { if err != nil { return 0, err } + if !since.IsZero() && info.ModTime().Before(since) { + continue + } files = append(files, replayFile{ path: filepath.Join(dir, entry.Name()), name: entry.Name(), @@ -50,7 +50,7 @@ func WarmDedup(dir string, d *ingestv2.Dedup) (int, error) { loaded := 0 for _, file := range files { - n, err := warmFile(file.path, d) + n, err := replayFileLines(file.path, fn) loaded += n if err != nil { return loaded, err @@ -65,7 +65,7 @@ type replayFile struct { modTime time.Time } -func warmFile(path string, d *ingestv2.Dedup) (int, error) { +func replayFileLines(path string, fn func(rawLine []byte) error) (int, error) { f, err := os.Open(path) if err != nil { return 0, err @@ -92,24 +92,12 @@ func warmFile(path string, d *ingestv2.Dedup) (int, error) { if readErr != nil && readErr != io.EOF { return loaded, readErr } - var raw map[string]any - if err := json.Unmarshal(line, &raw); err != nil { - slog.Warn("eventlogv2: skipping malformed line", "file", path, "line", lineNum, "err", err) - if readErr == io.EOF { - break + if len(line) > 0 && fn != nil { + if err := fn(line); err != nil { + return loaded, err } - continue - } - eventID, ok := raw["event_id"].(string) - if !ok || eventID == "" { - slog.Warn("eventlogv2: skipping line without event_id", "file", path, "line", lineNum) - if readErr == io.EOF { - break - } - continue + loaded++ } - d.Add(eventID) - loaded++ if readErr == io.EOF { break } @@ -122,6 +110,8 @@ func readReplayLine(r *bufio.Reader) ([]byte, bool, error) { tooLong := false for { frag, err := r.ReadSlice('\n') + // ReadSlice can return fragments before a newline; keep consuming them + // even after marking the line too long so the next read starts aligned. if !tooLong { if len(line)+len(frag) > maxReplayLineBytes { tooLong = true diff --git a/internal/eventlog/v2/replay_test.go b/internal/eventlog/v2/replay_test.go index a2b15dd..9d80465 100644 --- a/internal/eventlog/v2/replay_test.go +++ b/internal/eventlog/v2/replay_test.go @@ -7,71 +7,43 @@ import ( "strings" "testing" "time" - - ingestv2 "github.com/sssmaran/WaylogCLI/internal/ingest/v2" ) -func TestWarmDedupLoadsOldestFirstAndKeepsNewestAtCapacity(t *testing.T) { +func TestReplayWalksOldestFirst(t *testing.T) { dir := t.TempDir() now := time.Now() writeReplayFile(t, dir, "events-20260428-010000.jsonl", now.Add(-3*time.Hour), "a", "b") writeReplayFile(t, dir, "events-20260428-020000.jsonl", now.Add(-2*time.Hour), "c", "d") writeReplayFile(t, dir, "events-20260428-030000.jsonl", now.Add(-1*time.Hour), "e", "f") - d := ingestv2.NewDedup(4, nil) - loaded, err := WarmDedup(dir, d) - if err != nil { - t.Fatal(err) - } - if loaded != 6 { - t.Fatalf("loaded=%d want 6", loaded) - } - for _, id := range []string{"a", "b"} { - if d.Seen(id) { - t.Fatalf("%s should have been evicted", id) - } - } - for _, id := range []string{"c", "d", "e", "f"} { - if !d.Seen(id) { - t.Fatalf("%s should be present", id) + var ids []string + count, err := Replay(dir, time.Time{}, func(raw []byte) error { + var v map[string]string + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatal(err) } - } -} - -func TestWarmDedupSkipsMalformedLines(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "events-20260428-010000.jsonl") - body := strings.Join([]string{ - replayEventJSON(t, "a"), - "{bad", - `{"schema_version":"2.0"}`, - replayEventJSON(t, "b"), - }, "\n") + "\n" - if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - t.Fatal(err) - } - - d := ingestv2.NewDedup(10, nil) - loaded, err := WarmDedup(dir, d) + ids = append(ids, v["event_id"]) + return nil + }) if err != nil { t.Fatal(err) } - if loaded != 2 || !d.Seen("a") || !d.Seen("b") { - t.Fatalf("loaded=%d seen(a)=%v seen(b)=%v", loaded, d.Seen("a"), d.Seen("b")) + if count != 6 || strings.Join(ids, ",") != "a,b,c,d,e,f" { + t.Fatalf("count=%d ids=%v", count, ids) } } -func TestWarmDedupEmptyDir(t *testing.T) { - loaded, err := WarmDedup(t.TempDir(), ingestv2.NewDedup(10, nil)) +func TestReplayEmptyDir(t *testing.T) { + count, err := Replay(t.TempDir(), time.Time{}, func([]byte) error { return nil }) if err != nil { t.Fatal(err) } - if loaded != 0 { - t.Fatalf("loaded=%d want 0", loaded) + if count != 0 { + t.Fatalf("count=%d want 0", count) } } -func TestWarmDedupAcceptsOneMBLine(t *testing.T) { +func TestReplayAcceptsOneMBLine(t *testing.T) { dir := t.TempDir() raw := replayEventJSON(t, "a") paddingLen := (1 << 20) - len(raw) - len(`,"padding":""`) @@ -86,30 +58,61 @@ func TestWarmDedupAcceptsOneMBLine(t *testing.T) { t.Fatal(err) } - d := ingestv2.NewDedup(10, nil) - loaded, err := WarmDedup(dir, d) + count, err := Replay(dir, time.Time{}, func(raw []byte) error { + if len(raw) != len(withPadding) { + t.Fatalf("line=%d want %d", len(raw), len(withPadding)) + } + return nil + }) if err != nil { t.Fatal(err) } - if loaded != 1 || !d.Seen("a") { - t.Fatalf("loaded=%d seen=%v", loaded, d.Seen("a")) + if count != 1 { + t.Fatalf("count=%d want 1", count) } } -func TestWarmDedupSkipsOversizedLineAndContinues(t *testing.T) { +func TestReplaySkipsOversizedLineAndContinues(t *testing.T) { dir := t.TempDir() body := strings.Repeat("x", maxReplayLineBytes+1) + "\n" + replayEventJSON(t, "a") + "\n" if err := os.WriteFile(filepath.Join(dir, "events-20260428-010000.jsonl"), []byte(body), 0o644); err != nil { t.Fatal(err) } - d := ingestv2.NewDedup(10, nil) - loaded, err := WarmDedup(dir, d) + count, err := Replay(dir, time.Time{}, func(raw []byte) error { + if !strings.Contains(string(raw), `"event_id":"a"`) { + t.Fatalf("raw=%s", raw) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("count=%d want 1", count) + } +} + +func TestReplaySkipsOldFilesBySince(t *testing.T) { + dir := t.TempDir() + now := time.Now() + writeReplayFile(t, dir, "events-20260428-010000.jsonl", now.Add(-2*time.Hour), "old") + writeReplayFile(t, dir, "events-20260428-020000.jsonl", now, "new") + + var ids []string + count, err := Replay(dir, now.Add(-time.Hour), func(raw []byte) error { + var v map[string]string + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatal(err) + } + ids = append(ids, v["event_id"]) + return nil + }) if err != nil { t.Fatal(err) } - if loaded != 1 || !d.Seen("a") { - t.Fatalf("loaded=%d seen=%v", loaded, d.Seen("a")) + if count != 1 || len(ids) != 1 || ids[0] != "new" { + t.Fatalf("count=%d ids=%v", count, ids) } } diff --git a/internal/ingest/v2/dedup.go b/internal/ingest/v2/dedup.go index a9f7b03..1abc661 100644 --- a/internal/ingest/v2/dedup.go +++ b/internal/ingest/v2/dedup.go @@ -77,6 +77,23 @@ func (d *Dedup) AddIfNew(eventID string, commit func() error) (bool, error) { return false, nil } +// Remove forgets eventID. It is used only to roll back a same-process dedupe +// mark when post-WAL projection fails before the event can be accepted. +func (d *Dedup) Remove(eventID string) { + if d == nil || eventID == "" { + return + } + d.mu.Lock() + defer d.mu.Unlock() + elem, ok := d.items[eventID] + if !ok { + return + } + delete(d.items, eventID) + d.order.Remove(elem) + d.observeSizeLocked() +} + func (d *Dedup) addLocked(eventID string) { if elem, ok := d.items[eventID]; ok { d.order.MoveToFront(elem) diff --git a/internal/ingest/v2/dedup_test.go b/internal/ingest/v2/dedup_test.go index 6899482..2437f7c 100644 --- a/internal/ingest/v2/dedup_test.go +++ b/internal/ingest/v2/dedup_test.go @@ -78,3 +78,18 @@ func TestDedupGaugeTracksSize(t *testing.T) { t.Fatalf("gauge=%v want 2", got) } } + +func TestDedupRemove(t *testing.T) { + d := NewDedup(10, nil) + d.Add("a") + if !d.Seen("a") { + t.Fatal("a should be present") + } + d.Remove("a") + if d.Seen("a") { + t.Fatal("a should be removed") + } + if got := d.Size(); got != 0 { + t.Fatalf("Size=%d want 0", got) + } +} diff --git a/internal/ingest/v2/handler.go b/internal/ingest/v2/handler.go index 11b79c4..ca32b92 100644 --- a/internal/ingest/v2/handler.go +++ b/internal/ingest/v2/handler.go @@ -2,10 +2,12 @@ package ingestv2 import ( "encoding/json" + "log/slog" "net/http" "strings" "time" + "github.com/prometheus/client_golang/prometheus" "github.com/santhosh-tekuri/jsonschema/v5" "github.com/sssmaran/WaylogCLI/internal/metrics" eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" @@ -17,6 +19,7 @@ type Handler struct { metrics *metrics.Metrics dedup *Dedup wal WAL + project EventProjector } type WAL interface { @@ -27,6 +30,8 @@ type Config struct { Metrics *metrics.Metrics Dedup *Dedup WAL WAL + Index *RecentIndex + Project EventProjector } // New compiles the embedded v2.0 schema once for request-time reuse and @@ -51,7 +56,19 @@ func New(cfg Config) (*Handler, error) { } dedup = NewDedup(DefaultDedupCapacity, sizeGauge) } - return &Handler{schema: sch, metrics: m, dedup: dedup, wal: cfg.WAL}, nil + projector := cfg.Project + if projector == nil { + index := cfg.Index + if index == nil { + var sizeGauge *prometheus.GaugeVec + if m != nil { + sizeGauge = m.V2IndexSize + } + index = NewRecentIndex(sizeGauge) + } + projector = NewProjector(index) + } + return &Handler{schema: sch, metrics: m, dedup: dedup, wal: cfg.WAL, project: projector}, nil } var rejectionReasons = []string{ @@ -103,7 +120,11 @@ func (h *Handler) handle(w http.ResponseWriter, r *http.Request, durable bool) { env := newEnvelope() for i, eventBody := range parsed.events { + validateStart := time.Now() result := h.validateEvent(i, eventBody) + if h.metrics != nil { + h.metrics.V2ValidateLatency.Observe(time.Since(validateStart).Seconds()) + } if !result.ok { env.Rejected = append(env.Rejected, result.rejected) h.recordRejected(result.rejected.Reason) @@ -121,10 +142,10 @@ func (h *Handler) handle(w http.ResponseWriter, r *http.Request, durable bool) { var duplicate bool var err error if h.dedup == nil { - err = h.wal.WriteRaw(eventBody) + err = h.writeWAL(eventBody) } else { duplicate, err = h.dedup.AddIfNew(result.eventID, func() error { - return h.wal.WriteRaw(eventBody) + return h.writeWAL(eventBody) }) } if err != nil { @@ -139,6 +160,9 @@ func (h *Handler) handle(w http.ResponseWriter, r *http.Request, durable bool) { } continue } + if !h.projectEvent(w, result.eventID, result.event) { + return + } if h.metrics != nil { h.metrics.EventsAccepted.Inc() } @@ -150,6 +174,7 @@ func (h *Handler) handle(w http.ResponseWriter, r *http.Request, durable bool) { type validationResult struct { ok bool eventID string + event *eventv2.Event rejected RejectedEvent } @@ -166,7 +191,14 @@ func (h *Handler) validateEvent(index int, eventBody []byte) validationResult { if err := eventv2.ValidateAny(h.schema, raw); err != nil { return validationResult{eventID: eventID, rejected: RejectedEvent{Index: index, EventID: eventID, Reason: ReasonSchemaValidationFailed, Detail: err.Error()}} } - return validationResult{ok: true, eventID: eventID} + var ev eventv2.Event + if err := json.Unmarshal(eventBody, &ev); err != nil { + if h.metrics != nil { + h.metrics.V2TypedDecodeFailed.Inc() + } + return validationResult{eventID: eventID, rejected: RejectedEvent{Index: index, EventID: eventID, Reason: ReasonSchemaValidationFailed, Detail: "decode_typed: " + err.Error()}} + } + return validationResult{ok: true, eventID: eventID, event: &ev} } func rawIdentifiers(raw any) (eventID, schemaVersion string) { @@ -195,6 +227,43 @@ func (h *Handler) recordRejected(reason string) { } } +func (h *Handler) writeWAL(eventBody []byte) error { + start := time.Now() + err := h.wal.WriteRaw(eventBody) + if h.metrics != nil { + h.metrics.V2WALWriteLatency.Observe(time.Since(start).Seconds()) + } + return err +} + +func (h *Handler) projectEvent(w http.ResponseWriter, eventID string, ev *eventv2.Event) (ok bool) { + ok = false + start := time.Now() + defer func() { + if h.metrics != nil { + h.metrics.V2ProjectLatency.Observe(time.Since(start).Seconds()) + } + if recovered := recover(); recovered != nil { + if h.metrics != nil { + h.metrics.V2ProjectPanic.Inc() + } + if h.dedup != nil { + h.dedup.Remove(eventID) + } + slog.Error("ingestv2: projection panic", "event_id", eventID, "panic", recovered) + http.Error(w, "projection unavailable", http.StatusServiceUnavailable) + ok = false + } + }() + if h.project != nil { + h.project.Project(ev) + } + if h.metrics != nil { + h.metrics.V2EventsProjected.Inc() + } + return true +} + func writeEnvelope(w http.ResponseWriter, status int, env IngestEnvelope) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/internal/ingest/v2/handler_test.go b/internal/ingest/v2/handler_test.go index b5b59a5..bc1d483 100644 --- a/internal/ingest/v2/handler_test.go +++ b/internal/ingest/v2/handler_test.go @@ -17,6 +17,7 @@ import ( dto "github.com/prometheus/client_model/go" "github.com/sssmaran/WaylogCLI/internal/auth" "github.com/sssmaran/WaylogCLI/internal/metrics" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" ) func TestEventsSingleJSONValid(t *testing.T) { @@ -265,7 +266,8 @@ func TestEventsMetrics(t *testing.T) { func TestEventsDedupeWritesOnlyOnce(t *testing.T) { reg := prometheus.NewRegistry() m := metrics.New(reg) - h, wal := newTestHandler(t, m) + index := NewRecentIndex(nil) + h, wal := newTestHandlerWithIndex(t, m, index) body := validEventJSON("00000000-0000-4000-8000-000000000001") env := expectEnvelopeStatus(t, post(t, h, "application/json", "", body), http.StatusOK) @@ -279,12 +281,29 @@ func TestEventsDedupeWritesOnlyOnce(t *testing.T) { if got := wal.Count(); got != 1 { t.Fatalf("wal writes=%d want 1", got) } + if got := index.Sizes().Events; got != 1 { + t.Fatalf("indexed events=%d want 1", got) + } fm := gatherMap(t, reg) if got := counterValue(fm["waylog_events_duplicate_total"]); got != 1 { t.Fatalf("events_duplicate=%v want 1", got) } } +func TestEventsAcceptedEventIsIndexed(t *testing.T) { + index := NewRecentIndex(nil) + h, _ := newTestHandlerWithIndex(t, nil, index) + eventID := "00000000-0000-4000-8000-000000000001" + + env := expectEnvelopeStatus(t, post(t, h, "application/json", "", validEventJSON(eventID)), http.StatusOK) + if env.Accepted != 1 { + t.Fatalf("env=%+v", env) + } + if _, ok := index.GetByID(eventID); !ok { + t.Fatal("accepted event not indexed") + } +} + func TestEventsConcurrentDuplicateWritesOnce(t *testing.T) { wal := &fakeWAL{delay: 20 * time.Millisecond} h := newTestHandlerWithConfig(t, Config{ @@ -361,7 +380,8 @@ func TestEventsWALFailureReturnsPlain503(t *testing.T) { func TestEventsWALFailureMidBatchLeavesPriorEventDurableAndDeduped(t *testing.T) { wal := &fakeWAL{failAt: 2} dedup := NewDedup(10, nil) - h := newTestHandlerWithConfig(t, Config{Dedup: dedup, WAL: wal}) + index := NewRecentIndex(nil) + h := newTestHandlerWithConfig(t, Config{Dedup: dedup, WAL: wal, Index: index}) body := strings.Join([]string{ validEventJSON("00000000-0000-4000-8000-000000000001"), validEventJSON("00000000-0000-4000-8000-000000000002"), @@ -378,12 +398,16 @@ func TestEventsWALFailureMidBatchLeavesPriorEventDurableAndDeduped(t *testing.T) if !dedup.Seen("00000000-0000-4000-8000-000000000001") { t.Fatal("first event should be deduped after successful WAL write") } + if _, ok := index.GetByID("00000000-0000-4000-8000-000000000001"); !ok { + t.Fatal("first event should be indexed after successful WAL write") + } } func TestValidateDoesNotMutateWALOrDedup(t *testing.T) { dedup := NewDedup(10, nil) wal := &fakeWAL{} - h := newTestHandlerWithConfig(t, Config{Dedup: dedup, WAL: wal}) + index := NewRecentIndex(nil) + h := newTestHandlerWithConfig(t, Config{Dedup: dedup, WAL: wal, Index: index}) raw := validEventMap("00000000-0000-4000-8000-000000000002") delete(raw, "service") body := validEventJSON("00000000-0000-4000-8000-000000000001") + "\n" + mustJSON(t, raw) @@ -399,6 +423,44 @@ func TestValidateDoesNotMutateWALOrDedup(t *testing.T) { if dedup.Size() != 0 { t.Fatalf("dedup size=%d want 0", dedup.Size()) } + if index.Sizes().Events != 0 { + t.Fatalf("indexed events=%d want 0", index.Sizes().Events) + } +} + +func TestEventsProjectionPanicReturns503AndRollsBackDedup(t *testing.T) { + reg := prometheus.NewRegistry() + m := metrics.New(reg) + dedup := NewDedup(10, nil) + wal := &fakeWAL{} + h := newTestHandlerWithConfig(t, Config{ + Metrics: m, + Dedup: dedup, + WAL: wal, + Project: panicProjector{}, + }) + eventID := "00000000-0000-4000-8000-000000000001" + + rec := post(t, h, "application/json", "", validEventJSON(eventID)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "projection unavailable") { + t.Fatalf("body=%q", rec.Body.String()) + } + if wal.Count() != 1 { + t.Fatalf("wal writes=%d want 1", wal.Count()) + } + if dedup.Seen(eventID) { + t.Fatal("dedup entry should be rolled back") + } + fm := gatherMap(t, reg) + if got := counterValue(fm["waylog_events_accepted_total"]); got != 0 { + t.Fatalf("events_accepted=%v want 0", got) + } + if got := counterValue(fm["waylog_v2_project_panic_total"]); got != 1 { + t.Fatalf("project_panic=%v want 1", got) + } } func newTestHandler(t *testing.T, m *metrics.Metrics) (*Handler, *fakeWAL) { @@ -408,6 +470,13 @@ func newTestHandler(t *testing.T, m *metrics.Metrics) (*Handler, *fakeWAL) { return h, wal } +func newTestHandlerWithIndex(t *testing.T, m *metrics.Metrics, index *RecentIndex) (*Handler, *fakeWAL) { + t.Helper() + wal := &fakeWAL{} + h := newTestHandlerWithConfig(t, Config{Metrics: m, Dedup: NewDedup(DefaultDedupCapacity, nil), WAL: wal, Index: index}) + return h, wal +} + func newTestHandlerWithWAL(t *testing.T, m *metrics.Metrics, wal WAL) *Handler { t.Helper() return newTestHandlerWithConfig(t, Config{Metrics: m, Dedup: NewDedup(DefaultDedupCapacity, nil), WAL: wal}) @@ -614,3 +683,7 @@ var errFakeWAL = &fakeWALError{} type fakeWALError struct{} func (e *fakeWALError) Error() string { return "fake WAL failure" } + +type panicProjector struct{} + +func (panicProjector) Project(*eventv2.Event) { panic("boom") } diff --git a/internal/ingest/v2/index.go b/internal/ingest/v2/index.go new file mode 100644 index 0000000..893291a --- /dev/null +++ b/internal/ingest/v2/index.go @@ -0,0 +1,320 @@ +package ingestv2 + +import ( + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +type ErrorKey struct { + Service string + Step string + ErrorCode string +} + +type CallKey struct { + From string + To string + Endpoint string +} + +type serviceNode struct { + Name string + FirstSeen time.Time + LastSeen time.Time +} + +type errorNode struct { + Key ErrorKey + FirstSeen time.Time + LastSeen time.Time + Count int +} + +type callEdge struct { + Key CallKey + FirstSeen time.Time + LastSeen time.Time + Count int +} + +type IndexSizes struct { + Events int + Traces int + Services int + Errors int + Calls int +} + +type PruneResult struct { + Events int +} + +type RecentIndex struct { + mu sync.RWMutex + byID map[string]*eventv2.Event + byTrace map[string][]*eventv2.Event + services map[string]*serviceNode + errors map[ErrorKey]*errorNode + calls map[CallKey]*callEdge + size *prometheus.GaugeVec +} + +func NewRecentIndex(sizeGauge *prometheus.GaugeVec) *RecentIndex { + idx := &RecentIndex{ + byID: map[string]*eventv2.Event{}, + byTrace: map[string][]*eventv2.Event{}, + services: map[string]*serviceNode{}, + errors: map[ErrorKey]*errorNode{}, + calls: map[CallKey]*callEdge{}, + size: sizeGauge, + } + idx.observeLocked() + return idx +} + +func (idx *RecentIndex) Insert(ev *eventv2.Event) bool { + if idx == nil || ev == nil || ev.EventID == "" { + return false + } + idx.mu.Lock() + defer idx.mu.Unlock() + if _, ok := idx.byID[ev.EventID]; ok { + return false + } + cp := cloneEvent(ev) + idx.byID[cp.EventID] = cp + idx.byTrace[cp.TraceID] = append(idx.byTrace[cp.TraceID], cp) + idx.applyAggregatesLocked(cp) + idx.observeLocked() + return true +} + +func (idx *RecentIndex) GetByID(eventID string) (*eventv2.Event, bool) { + if idx == nil || eventID == "" { + return nil, false + } + idx.mu.RLock() + defer idx.mu.RUnlock() + ev, ok := idx.byID[eventID] + if !ok { + return nil, false + } + return cloneEvent(ev), true +} + +func (idx *RecentIndex) TraceEvents(traceID string) []*eventv2.Event { + if idx == nil || traceID == "" { + return nil + } + idx.mu.RLock() + defer idx.mu.RUnlock() + events := idx.byTrace[traceID] + out := make([]*eventv2.Event, 0, len(events)) + for _, ev := range events { + out = append(out, cloneEvent(ev)) + } + return out +} + +func (idx *RecentIndex) Sizes() IndexSizes { + if idx == nil { + return IndexSizes{} + } + idx.mu.RLock() + defer idx.mu.RUnlock() + return idx.sizesLocked() +} + +func (idx *RecentIndex) PruneOlderThan(cutoff time.Time) PruneResult { + if idx == nil || cutoff.IsZero() { + return PruneResult{} + } + idx.mu.Lock() + defer idx.mu.Unlock() + + pruned := 0 + keptByTrace := make(map[string][]*eventv2.Event, len(idx.byTrace)) + for traceID, events := range idx.byTrace { + for _, ev := range events { + if ev.TsEnd.Before(cutoff) { + pruned++ + continue + } + keptByTrace[traceID] = append(keptByTrace[traceID], ev) + } + } + if pruned == 0 { + idx.observeLocked() + return PruneResult{} + } + + idx.byID = map[string]*eventv2.Event{} + idx.byTrace = map[string][]*eventv2.Event{} + idx.services = map[string]*serviceNode{} + idx.errors = map[ErrorKey]*errorNode{} + idx.calls = map[CallKey]*callEdge{} + for traceID, events := range keptByTrace { + if len(events) == 0 { + continue + } + idx.byTrace[traceID] = events + for _, ev := range events { + idx.byID[ev.EventID] = ev + idx.applyAggregatesLocked(ev) + } + } + idx.observeLocked() + return PruneResult{Events: pruned} +} + +func (idx *RecentIndex) applyAggregatesLocked(ev *eventv2.Event) { + ts := ev.TsEnd + if ts.IsZero() { + ts = ev.TsStart + } + idx.touchServiceLocked(ev.Service, ts) + + if ev.Status == eventv2.StatusSuppressed { + return + } + + if isFailedStatus(ev.Status) && ev.Anchor != nil { + key := ErrorKey{Service: ev.Service, Step: ev.Anchor.Step, ErrorCode: ev.Anchor.ErrorCode} + node := idx.errors[key] + if node == nil { + node = &errorNode{Key: key, FirstSeen: ts} + idx.errors[key] = node + } + node.Count++ + touchRange(&node.FirstSeen, &node.LastSeen, ts) + } + + for _, step := range ev.Steps { + if step.Downstream == nil || step.Downstream.Service == "" { + continue + } + key := CallKey{From: ev.Service, To: step.Downstream.Service, Endpoint: step.Downstream.Endpoint} + edge := idx.calls[key] + if edge == nil { + edge = &callEdge{Key: key, FirstSeen: ts} + idx.calls[key] = edge + } + edge.Count++ + touchRange(&edge.FirstSeen, &edge.LastSeen, ts) + } +} + +func (idx *RecentIndex) touchServiceLocked(service string, ts time.Time) { + if service == "" { + return + } + node := idx.services[service] + if node == nil { + node = &serviceNode{Name: service, FirstSeen: ts} + idx.services[service] = node + } + touchRange(&node.FirstSeen, &node.LastSeen, ts) +} + +func (idx *RecentIndex) sizesLocked() IndexSizes { + return IndexSizes{ + Events: len(idx.byID), + Traces: len(idx.byTrace), + Services: len(idx.services), + Errors: len(idx.errors), + Calls: len(idx.calls), + } +} + +func (idx *RecentIndex) observeLocked() { + if idx.size == nil { + return + } + sizes := idx.sizesLocked() + idx.size.WithLabelValues("event").Set(float64(sizes.Events)) + idx.size.WithLabelValues("trace").Set(float64(sizes.Traces)) + idx.size.WithLabelValues("service").Set(float64(sizes.Services)) + idx.size.WithLabelValues("error").Set(float64(sizes.Errors)) + idx.size.WithLabelValues("call").Set(float64(sizes.Calls)) +} + +func touchRange(first, last *time.Time, ts time.Time) { + if ts.IsZero() { + return + } + if first.IsZero() || ts.Before(*first) { + *first = ts + } + if last.IsZero() || ts.After(*last) { + *last = ts + } +} + +func isFailedStatus(status eventv2.Status) bool { + switch status { + case eventv2.StatusError, eventv2.StatusTimeout, eventv2.StatusPartial, eventv2.StatusAborted: + return true + default: + return false + } +} + +func cloneEvent(ev *eventv2.Event) *eventv2.Event { + if ev == nil { + return nil + } + cp := *ev + if ev.Anchor != nil { + anchor := *ev.Anchor + cp.Anchor = &anchor + } + cp.Steps = append([]eventv2.Step(nil), ev.Steps...) + for i := range cp.Steps { + if ev.Steps[i].Downstream != nil { + downstream := *ev.Steps[i].Downstream + cp.Steps[i].Downstream = &downstream + } + if ev.Steps[i].Error != nil { + stepErr := *ev.Steps[i].Error + cp.Steps[i].Error = &stepErr + } + } + cp.Logs = append([]eventv2.Log(nil), ev.Logs...) + for i := range cp.Logs { + cp.Logs[i].Fields = cloneMap(ev.Logs[i].Fields) + } + cp.Errors = append([]eventv2.ErrorRef(nil), ev.Errors...) + cp.Fields = cloneMap(ev.Fields) + return &cp +} + +func cloneMap(in map[string]any) map[string]any { + if len(in) == 0 { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = cloneValue(v) + } + return out +} + +// cloneValue handles value shapes produced by json.Unmarshal. Maps/slices are +// copied recursively; primitive values are immutable and can be reused. +func cloneValue(v any) any { + switch typed := v.(type) { + case map[string]any: + return cloneMap(typed) + case []any: + out := make([]any, len(typed)) + for i := range typed { + out[i] = cloneValue(typed[i]) + } + return out + default: + return v + } +} diff --git a/internal/ingest/v2/projector.go b/internal/ingest/v2/projector.go new file mode 100644 index 0000000..042abfd --- /dev/null +++ b/internal/ingest/v2/projector.go @@ -0,0 +1,28 @@ +package ingestv2 + +import ( + "log/slog" + + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +type EventProjector interface { + Project(*eventv2.Event) +} + +type Projector struct { + index *RecentIndex +} + +func NewProjector(index *RecentIndex) *Projector { + return &Projector{index: index} +} + +func (p *Projector) Project(ev *eventv2.Event) { + if p == nil || p.index == nil { + return + } + if !p.index.Insert(ev) && ev != nil { + slog.Warn("ingestv2: projector saw duplicate event_id post-dedup", "event_id", ev.EventID) + } +} diff --git a/internal/ingest/v2/projector_test.go b/internal/ingest/v2/projector_test.go new file mode 100644 index 0000000..8cce9c8 --- /dev/null +++ b/internal/ingest/v2/projector_test.go @@ -0,0 +1,150 @@ +package ingestv2 + +import ( + "testing" + "time" + + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +func TestProjectorIndexesHappyPathEvent(t *testing.T) { + idx := NewRecentIndex(nil) + NewProjector(idx).Project(testEvent("event-1", eventv2.StatusOK)) + + if _, ok := idx.GetByID("event-1"); !ok { + t.Fatal("event not indexed by id") + } + if got := len(idx.TraceEvents("trace-1")); got != 1 { + t.Fatalf("trace events=%d want 1", got) + } + sizes := idx.Sizes() + if sizes.Events != 1 || sizes.Traces != 1 || sizes.Services != 1 || sizes.Errors != 0 || sizes.Calls != 0 { + t.Fatalf("sizes=%+v", sizes) + } +} + +func TestProjectorIndexesFailureTriplets(t *testing.T) { + for _, status := range []eventv2.Status{eventv2.StatusError, eventv2.StatusTimeout, eventv2.StatusPartial, eventv2.StatusAborted} { + t.Run(string(status), func(t *testing.T) { + idx := NewRecentIndex(nil) + ev := testEvent("event-1", status) + ev.Anchor = &eventv2.Anchor{Step: "payment.charge", ErrorCode: "PMT_502"} + NewProjector(idx).Project(ev) + + key := ErrorKey{Service: "checkout", Step: "payment.charge", ErrorCode: "PMT_502"} + node := idx.errors[key] + if node == nil || node.Count != 1 { + t.Fatalf("error node=%+v", node) + } + }) + } +} + +func TestProjectorSuppressedCreatesNoFailureOrCallEdges(t *testing.T) { + idx := NewRecentIndex(nil) + ev := testEvent("event-1", eventv2.StatusSuppressed) + ev.Steps = []eventv2.Step{{ + Name: "payment.charge", + StartMS: 0, + DurationMS: 5, + Status: "ok", + Downstream: &eventv2.Downstream{Service: "payment", Endpoint: "/charge"}, + }} + NewProjector(idx).Project(ev) + + sizes := idx.Sizes() + if sizes.Events != 1 || sizes.Services != 1 || sizes.Errors != 0 || sizes.Calls != 0 { + t.Fatalf("sizes=%+v", sizes) + } +} + +func TestProjectorCallsOnlyFromStructuredDownstream(t *testing.T) { + idx := NewRecentIndex(nil) + ev := testEvent("event-1", eventv2.StatusOK) + ev.Steps = []eventv2.Step{ + {Name: "payment.charge", StartMS: 0, DurationMS: 5, Status: "ok"}, + {Name: "call payment", StartMS: 5, DurationMS: 5, Status: "ok", Downstream: &eventv2.Downstream{Service: "payment", Endpoint: "/charge"}}, + {Name: "call payment again", StartMS: 10, DurationMS: 5, Status: "ok", Downstream: &eventv2.Downstream{Service: "payment", Endpoint: "/charge"}}, + } + NewProjector(idx).Project(ev) + + if len(idx.calls) != 1 { + t.Fatalf("calls=%+v", idx.calls) + } + key := CallKey{From: "checkout", To: "payment", Endpoint: "/charge"} + edge := idx.calls[key] + if edge == nil || edge.Count != 2 { + t.Fatalf("edge=%+v", edge) + } +} + +func TestRecentIndexPruneOlderThan(t *testing.T) { + idx := NewRecentIndex(nil) + old := testEvent("old", eventv2.StatusError) + old.TsEnd = time.Date(2026, 4, 25, 14, 0, 0, 0, time.UTC) + old.Anchor = &eventv2.Anchor{Step: "payment.charge", ErrorCode: "PMT_502"} + newer := testEvent("new", eventv2.StatusOK) + newer.TsEnd = old.TsEnd.Add(2 * time.Hour) + NewProjector(idx).Project(old) + NewProjector(idx).Project(newer) + + res := idx.PruneOlderThan(old.TsEnd.Add(time.Hour)) + if res.Events != 1 { + t.Fatalf("pruned=%d want 1", res.Events) + } + if _, ok := idx.GetByID("old"); ok { + t.Fatal("old event should be pruned") + } + if _, ok := idx.GetByID("new"); !ok { + t.Fatal("new event should remain") + } + sizes := idx.Sizes() + if sizes.Events != 1 || sizes.Errors != 0 { + t.Fatalf("sizes=%+v", sizes) + } +} + +func TestRecentIndexPrunePreservesTraceOrder(t *testing.T) { + idx := NewRecentIndex(nil) + base := time.Date(2026, 4, 25, 14, 0, 0, 0, time.UTC) + for i, id := range []string{"a", "b", "c"} { + ev := testEvent(id, eventv2.StatusOK) + ev.TraceID = "trace-order" + ev.TsEnd = base.Add(time.Duration(i) * time.Minute) + NewProjector(idx).Project(ev) + } + + idx.PruneOlderThan(base.Add(30 * time.Second)) + events := idx.TraceEvents("trace-order") + if len(events) != 2 || events[0].EventID != "b" || events[1].EventID != "c" { + t.Fatalf("events=%v", eventIDs(events)) + } +} + +func eventIDs(events []*eventv2.Event) []string { + out := make([]string, 0, len(events)) + for _, ev := range events { + out = append(out, ev.EventID) + } + return out +} + +func testEvent(id string, status eventv2.Status) *eventv2.Event { + return &eventv2.Event{ + SchemaVersion: eventv2.SchemaVersion2, + EventID: id, + TsStart: time.Date(2026, 4, 25, 14, 0, 0, 0, time.UTC), + TsEnd: time.Date(2026, 4, 25, 14, 0, 0, 10*int(time.Millisecond), time.UTC), + DurationMS: 10, + Kind: "http", + Service: "checkout", + Env: "test", + TraceID: "trace-1", + SpanID: "span-1", + Status: status, + Steps: []eventv2.Step{}, + Logs: []eventv2.Log{}, + Fields: map[string]any{}, + Errors: []eventv2.ErrorRef{}, + } +} diff --git a/internal/ingest/v2/replay.go b/internal/ingest/v2/replay.go new file mode 100644 index 0000000..20801ec --- /dev/null +++ b/internal/ingest/v2/replay.go @@ -0,0 +1,73 @@ +package ingestv2 + +import ( + "encoding/json" + "log/slog" + "time" + + eventlogv2 "github.com/sssmaran/WaylogCLI/internal/eventlog/v2" + "github.com/sssmaran/WaylogCLI/internal/metrics" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +const replayRotationSlack = 5 * time.Minute + +type ReplayResult struct { + DedupLoaded int + Projected int + DecodeFails int +} + +func ReplayWAL(dir string, dedup *Dedup, projector EventProjector, since time.Time, m *metrics.Metrics) (ReplayResult, error) { + schema, err := eventv2.CompileEmbeddedSchema() + if err != nil { + return ReplayResult{}, err + } + fileSince := since + if !fileSince.IsZero() { + fileSince = fileSince.Add(-replayRotationSlack) + } + + var result ReplayResult + _, err = eventlogv2.Replay(dir, fileSince, func(rawLine []byte) error { + var raw any + if err := json.Unmarshal(rawLine, &raw); err != nil { + recordReplaySkip(m, "malformed_json") + slog.Warn("ingestv2: skipping malformed replay line", "err", err) + return nil + } + if err := eventv2.ValidateAny(schema, raw); err != nil { + recordReplaySkip(m, "schema_invalid") + slog.Warn("ingestv2: skipping schema-invalid replay line", "err", err) + return nil + } + var ev eventv2.Event + if err := json.Unmarshal(rawLine, &ev); err != nil { + result.DecodeFails++ + if m != nil { + m.V2TypedDecodeFailed.Inc() + } + recordReplaySkip(m, "typed_decode") + slog.Warn("ingestv2: skipping typed-decode replay line", "err", err) + return nil + } + if !since.IsZero() && ev.TsEnd.Before(since) { + recordReplaySkip(m, "stale") + return nil + } + dedup.Add(ev.EventID) + result.DedupLoaded++ + if projector != nil { + projector.Project(&ev) + result.Projected++ + } + return nil + }) + return result, err +} + +func recordReplaySkip(m *metrics.Metrics, reason string) { + if m != nil { + m.V2ReplaySkipped.WithLabelValues(reason).Inc() + } +} diff --git a/internal/ingest/v2/replay_test.go b/internal/ingest/v2/replay_test.go new file mode 100644 index 0000000..ba35741 --- /dev/null +++ b/internal/ingest/v2/replay_test.go @@ -0,0 +1,131 @@ +package ingestv2 + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/sssmaran/WaylogCLI/internal/metrics" +) + +func TestReplayWALRebuildsDedupAndIndex(t *testing.T) { + dir := t.TempDir() + writeV2ReplayFile(t, dir, "events-20260428-010000.jsonl", time.Now(), []string{ + validEventJSON("00000000-0000-4000-8000-000000000001"), + validEventJSON("00000000-0000-4000-8000-000000000002"), + }) + dedup := NewDedup(10, nil) + idx := NewRecentIndex(nil) + + res, err := ReplayWAL(dir, dedup, NewProjector(idx), time.Time{}, nil) + if err != nil { + t.Fatal(err) + } + if res.DedupLoaded != 2 || res.Projected != 2 { + t.Fatalf("replay=%+v", res) + } + if !dedup.Seen("00000000-0000-4000-8000-000000000001") { + t.Fatal("dedup not warmed") + } + if _, ok := idx.GetByID("00000000-0000-4000-8000-000000000002"); !ok { + t.Fatal("event not indexed") + } +} + +func TestReplayWALSkipsBadLinesAndContinues(t *testing.T) { + dir := t.TempDir() + reg := prometheus.NewRegistry() + m := metrics.New(reg) + raw := validEventMap("00000000-0000-4000-8000-000000000001") + delete(raw, "service") + writeV2ReplayFile(t, dir, "events-20260428-010000.jsonl", time.Now(), []string{ + "{bad", + strings.Repeat("x", maxBodyBytes+2), + mustJSON(t, raw), + validEventJSON("00000000-0000-4000-8000-000000000002"), + }) + idx := NewRecentIndex(nil) + + res, err := ReplayWAL(dir, NewDedup(10, nil), NewProjector(idx), time.Time{}, m) + if err != nil { + t.Fatal(err) + } + if res.Projected != 1 { + t.Fatalf("projected=%d want 1", res.Projected) + } + if _, ok := idx.GetByID("00000000-0000-4000-8000-000000000002"); !ok { + t.Fatal("valid event not indexed") + } + fm := gatherMap(t, reg) + if got := counterWithLabel(fm["waylog_v2_replay_skipped_total"], "reason", "malformed_json"); got != 1 { + t.Fatalf("malformed_json skips=%v want 1", got) + } + if got := counterWithLabel(fm["waylog_v2_replay_skipped_total"], "reason", "schema_invalid"); got != 1 { + t.Fatalf("schema_invalid skips=%v want 1", got) + } +} + +func TestReplayWALSkipsOldFilesAndOldEvents(t *testing.T) { + dir := t.TempDir() + cutoff := time.Date(2026, 4, 25, 13, 0, 0, 0, time.UTC) + writeV2ReplayFile(t, dir, "events-20260428-010000.jsonl", cutoff.Add(-10*time.Minute), []string{ + validEventJSON("00000000-0000-4000-8000-000000000001"), + }) + oldEvent := validEventMap("00000000-0000-4000-8000-000000000002") + oldEvent["ts_start"] = "2026-04-25T10:00:00.000Z" + oldEvent["ts_end"] = "2026-04-25T10:00:00.010Z" + writeV2ReplayFile(t, dir, "events-20260428-020000.jsonl", cutoff.Add(10*time.Minute), []string{ + mustJSON(t, oldEvent), + validEventJSON("00000000-0000-4000-8000-000000000003"), + }) + idx := NewRecentIndex(nil) + + res, err := ReplayWAL(dir, NewDedup(10, nil), NewProjector(idx), cutoff, nil) + if err != nil { + t.Fatal(err) + } + if res.Projected != 1 { + t.Fatalf("projected=%d want 1", res.Projected) + } + if _, ok := idx.GetByID("00000000-0000-4000-8000-000000000003"); !ok { + t.Fatal("new event not indexed") + } + if _, ok := idx.GetByID("00000000-0000-4000-8000-000000000002"); ok { + t.Fatal("old event should be skipped") + } +} + +func TestReplayWALPreservesNewestAtDedupCapacity(t *testing.T) { + dir := t.TempDir() + writeV2ReplayFile(t, dir, "events-20260428-010000.jsonl", time.Now(), []string{ + validEventJSON("00000000-0000-4000-8000-000000000001"), + validEventJSON("00000000-0000-4000-8000-000000000002"), + validEventJSON("00000000-0000-4000-8000-000000000003"), + }) + dedup := NewDedup(2, nil) + + if _, err := ReplayWAL(dir, dedup, NewProjector(NewRecentIndex(nil)), time.Time{}, nil); err != nil { + t.Fatal(err) + } + if dedup.Seen("00000000-0000-4000-8000-000000000001") { + t.Fatal("oldest id should be evicted") + } + if !dedup.Seen("00000000-0000-4000-8000-000000000002") || !dedup.Seen("00000000-0000-4000-8000-000000000003") { + t.Fatal("newest ids should remain") + } +} + +func writeV2ReplayFile(t *testing.T, dir, name string, modTime time.Time, lines []string) { + t.Helper() + body := strings.Join(lines, "\n") + "\n" + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, modTime, modTime); err != nil { + t.Fatal(err) + } +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index f0bcd30..68c8007 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -22,6 +22,16 @@ type Metrics struct { EventlogFails prometheus.Counter EventDedupCacheSize prometheus.Gauge EventDedupReplayLoaded prometheus.Counter + V2EventsProjected prometheus.Counter + V2IndexSize *prometheus.GaugeVec + V2IndexPruned prometheus.Counter + V2ReplayProjected prometheus.Counter + V2ReplaySkipped *prometheus.CounterVec + V2TypedDecodeFailed prometheus.Counter + V2ProjectPanic prometheus.Counter + V2ValidateLatency prometheus.Histogram + V2WALWriteLatency prometheus.Histogram + V2ProjectLatency prometheus.Histogram ReplayLagSeconds prometheus.Gauge ReplayInProgress prometheus.Gauge @@ -77,7 +87,7 @@ func New(reg *prometheus.Registry) *Metrics { m.IngestLatency = prometheus.NewHistogram(prometheus.HistogramOpts{ Name: "waylog_ingest_latency_seconds", - Help: "Full Events handler latency.", + Help: "Full Events handler latency, including parsing and response encoding. For v2 sub-step latency see waylog_v2_*_latency_seconds.", Buckets: defaultBuckets, }) m.IngestBatchSize = prometheus.NewHistogram(prometheus.HistogramOpts{ @@ -92,7 +102,7 @@ func New(reg *prometheus.Registry) *Metrics { }) m.EventsAccepted = prometheus.NewCounter(prometheus.CounterOpts{ Name: "waylog_events_accepted_total", - Help: "Events durably written to WAL.", + Help: "Events accepted after each ingest path's durability contract is satisfied.", }) m.EventsDuplicate = prometheus.NewCounter(prometheus.CounterOpts{ Name: "waylog_events_duplicate_total", @@ -117,6 +127,55 @@ func New(reg *prometheus.Registry) *Metrics { Name: "waylog_event_dedup_replay_loaded_total", Help: "Schema-2.0 event IDs loaded into dedup cache during WAL replay.", }) + m.V2EventsProjected = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "waylog_v2_events_projected_total", + Help: "Schema-2.0 events projected into the recent index from live ingest.", + }) + m.V2IndexSize = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "waylog_v2_index_size", + Help: "Current schema-2.0 recent index size by kind.", + }, []string{"kind"}) + for _, kind := range []string{"event", "trace", "service", "error", "call"} { + m.V2IndexSize.WithLabelValues(kind).Set(0) + } + m.V2IndexPruned = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "waylog_v2_index_pruned_total", + Help: "Schema-2.0 events pruned from the recent index.", + }) + m.V2ReplayProjected = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "waylog_v2_replay_projected_total", + Help: "Schema-2.0 events projected into the recent index during WAL replay.", + }) + m.V2ReplaySkipped = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "waylog_v2_replay_skipped_total", + Help: "Schema-2.0 WAL replay lines skipped by reason.", + }, []string{"reason"}) + for _, reason := range []string{"malformed_json", "schema_invalid", "typed_decode", "stale"} { + m.V2ReplaySkipped.WithLabelValues(reason).Add(0) + } + m.V2TypedDecodeFailed = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "waylog_v2_typed_decode_failed_total", + Help: "Schema-2.0 events that passed raw schema validation but failed typed decode.", + }) + m.V2ProjectPanic = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "waylog_v2_project_panic_total", + Help: "Recovered panics while projecting schema-2.0 events into the recent index.", + }) + m.V2ValidateLatency = prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "waylog_v2_validate_latency_seconds", + Help: "Schema-2.0 per-event raw validation and typed decode latency.", + Buckets: defaultBuckets, + }) + m.V2WALWriteLatency = prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "waylog_v2_wal_write_latency_seconds", + Help: "Schema-2.0 per-event WAL write latency.", + Buckets: defaultBuckets, + }) + m.V2ProjectLatency = prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "waylog_v2_project_latency_seconds", + Help: "Schema-2.0 per-event recent-index projection latency.", + Buckets: defaultBuckets, + }) m.ReplayLagSeconds = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "waylog_replay_lag_seconds", @@ -297,6 +356,10 @@ func New(reg *prometheus.Registry) *Metrics { m.IngestLatency, m.IngestBatchSize, m.MergeLatency, m.EventsAccepted, m.EventsDuplicate, m.EventsRejected, m.EventlogFails, m.EventDedupCacheSize, m.EventDedupReplayLoaded, + m.V2EventsProjected, m.V2IndexSize, m.V2IndexPruned, m.V2ReplayProjected, + m.V2ReplaySkipped, + m.V2TypedDecodeFailed, m.V2ProjectPanic, + m.V2ValidateLatency, m.V2WALWriteLatency, m.V2ProjectLatency, m.ReplayLagSeconds, m.ReplayInProgress, m.ReplayFailuresTotal, m.Ready, m.InFlightRequests, m.SnapshotLastSuccess, m.SnapshotLastError, From 20f5778d96c35742b7073a55790de80f93e9874b Mon Sep 17 00:00:00 2001 From: skota-hash Date: Tue, 28 Apr 2026 22:03:33 -0400 Subject: [PATCH 04/12] feat(ingest): added v2 recent-index read APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added the dark-launched schema-2.0 read surface behind WAYLOG_V2_READS. When the flag is enabled, the ingest server serves v2 reads from the in-memory RecentIndex instead of the legacy v1 graph for index-direct and derived read endpoints. Add index-direct endpoints for /v1/events/{event_id}, /v1/events/search, /v1/traces/{trace_id}, and /v1/traces/recent with strict query parsing, opaque cursor pagination, v2 JSON error envelopes, suppressed-event semantics, and hot-window bounds. RecentIndex now exposes snapshot helpers that copy pointer slices under RLock so readers can sort/filter outside the index lock. Add trace ordering and the §5.5 failure-anchor resolver as pure helpers. Trace ordering uses only structured parent_span_id ↔ steps[].span_id linkage and never parses step names. The resolver supports suppressed-event exclusion for story resolution while preserving Slice 4 recent-trace summary behavior. Add derived v2 read endpoints for /v1/traces/story, /v1/errors, and /v1/blast_radius. Story responses use explicit snake_case DTOs and derive anchor metadata, contributing steps, warn/error logs, downstream edges, route, status, and linkage from the selected event. Error rollups group by {service, step, error_code}, paginate by count/triplet, and compute affected trace/user counts plus deterministic sample traces. Blast radius supports structured triplets, escaped display-form error families, and bare cross-family error_code queries. Route all v2 read handlers only when WAYLOG_V2_READS=true. With the flag off, existing v1 story/search/recent/blast routes remain unchanged and /v1/errors stays unavailable. Add v2 read latency, empty-result, and not-found metrics with preinitialized handler labels. Document the feature flag and v2 read status in README, CHANGELOG, and docs/env.md. Tests cover cursor handling, query validation, trace ordering, anchor resolution, index-direct reads, story derivation, suppressed-event behavior, error-family aggregation, blast-radius keying/counts, route-prefix safety, CORS preflight, JSON error envelopes, and read metrics. --- README.md | 23 +- cmd/ingest/main.go | 24 +- docs/env.md | 2 + internal/ingest/v2/anchor.go | 160 ++++++++ internal/ingest/v2/cursor.go | 127 ++++++ internal/ingest/v2/cursor_test.go | 39 ++ internal/ingest/v2/derived.go | 537 +++++++++++++++++++++++++ internal/ingest/v2/derived_test.go | 192 +++++++++ internal/ingest/v2/index.go | 35 ++ internal/ingest/v2/linkage.go | 169 ++++++++ internal/ingest/v2/linkage_test.go | 151 +++++++ internal/ingest/v2/queryparams.go | 148 +++++++ internal/ingest/v2/reader.go | 266 ++++++++++++ internal/ingest/v2/reader_test.go | 114 ++++++ internal/ingest/v2/readhandler.go | 501 +++++++++++++++++++++++ internal/ingest/v2/readhandler_test.go | 204 ++++++++++ internal/metrics/metrics.go | 22 + 17 files changed, 2700 insertions(+), 14 deletions(-) create mode 100644 internal/ingest/v2/anchor.go create mode 100644 internal/ingest/v2/cursor.go create mode 100644 internal/ingest/v2/cursor_test.go create mode 100644 internal/ingest/v2/derived.go create mode 100644 internal/ingest/v2/derived_test.go create mode 100644 internal/ingest/v2/linkage.go create mode 100644 internal/ingest/v2/linkage_test.go create mode 100644 internal/ingest/v2/queryparams.go create mode 100644 internal/ingest/v2/reader.go create mode 100644 internal/ingest/v2/reader_test.go create mode 100644 internal/ingest/v2/readhandler.go create mode 100644 internal/ingest/v2/readhandler_test.go diff --git a/README.md b/README.md index bce4062..9d67739 100644 --- a/README.md +++ b/README.md @@ -54,12 +54,14 @@ npm install @waylog/sdk ```ts import { waylog, useLogger } from "@waylog/sdk/express"; -app.use(waylog({ - service: "checkout", - env: "prod", - ingestUrl: "http://localhost:8080", - apiKey: process.env.WAYLOG_WRITE_KEY, -})); +app.use( + waylog({ + service: "checkout", + env: "prod", + ingestUrl: "http://localhost:8080", + apiKey: process.env.WAYLOG_WRITE_KEY, + }), +); app.post("/buy", (req, res) => { useLogger(req).info("cart loaded", { user_id: req.user.id, tier: "vip" }); @@ -256,11 +258,11 @@ make ci # fmt + vet + test-race + test-sdk + ts-test + doc-link + ro Waylog uses three scoped keys. They are independent — the dashboard never holds the agent key. -| Key | Protects | -| ------------------ | --------------------------------------- | +| Key | Protects | +| ------------------ | ----------------------------------------------------- | | `WAYLOG_WRITE_KEY` | `/v1/events`, `/v1/otlp/v1/traces` (SDKs, collectors) | -| `WAYLOG_READ_KEY` | Read APIs, dashboard session | -| `WAYLOG_AGENT_KEY` | `/v1/tools/*`, `/v1/ask`, `/v1/plans/*` | +| `WAYLOG_READ_KEY` | Read APIs, dashboard session | +| `WAYLOG_AGENT_KEY` | `/v1/tools/*`, `/v1/ask`, `/v1/plans/*` | `WAYLOG_API_KEY` is a legacy alias for the write scope. `ParseConfig` validates the auth matrix at startup and refuses to boot with an unsafe combination. @@ -274,6 +276,7 @@ Public alpha. APIs may break before 1.0. - OTLP/HTTP traces at `/v1/otlp/v1/traces` (Phase A — traces only) - durable ingest with WAL + replay - hot graph with flattened 3-node model + dedicated trace store +- schema-2.0 recent-index read APIs behind `WAYLOG_V2_READS=true` - SQLite cold store (events, deployments, causal claims) - 10 deterministic analysis tools, rollup-correct root-cause attribution - agent-native REST (`/v1/tools/*`, `/v1/ask`, `/v1/plans/execute`) with idempotency and structured envelopes diff --git a/cmd/ingest/main.go b/cmd/ingest/main.go index c9997aa..e26a724 100644 --- a/cmd/ingest/main.go +++ b/cmd/ingest/main.go @@ -123,6 +123,7 @@ func main() { grafanaURL := config.Getenv("GRAFANA_URL", "") graphUI := config.GetenvBool("GRAPH_UI", false) otlpEnabled := config.GetenvBool("OTLP_ENABLED", true) + v2ReadsEnabled := config.GetenvBool("WAYLOG_V2_READS", false) causalEnabled := config.GetenvBool("CAUSAL_ENABLED", false) causalInterval := config.GetenvDuration("CAUSAL_INTERVAL", 30*time.Second) @@ -383,15 +384,30 @@ func main() { return http.HandlerFunc(ingest.CORSWrap(corsOrigin, "GET, OPTIONS", func(w http.ResponseWriter, r *http.Request) { inner.ServeHTTP(w, r) })) } - mux.Handle("/v1/traces/story", readCORS(ingestServer.TraceStory)) - mux.Handle("/v1/traces/recent", readCORS(ingestServer.RecentTraces)) mux.Handle("/v1/overview", readCORS(ingestServer.Overview)) - mux.Handle("/v1/events/search", readCORS(ingestServer.EventSearch)) + if v2ReadsEnabled { + v2Reader := ingestv2.NewReader(v2Index) + v2ReadHandler := ingestv2.NewReadHandler(v2Reader, m, graphHotWindow) + mux.Handle("/v1/events/search", readCORS(v2ReadHandler.EventSearch)) + mux.Handle("/v1/errors", readCORS(v2ReadHandler.Errors)) + mux.Handle("/v1/blast_radius", readCORS(v2ReadHandler.BlastRadius)) + mux.Handle("/v1/traces/story", readCORS(v2ReadHandler.TraceStory)) + mux.Handle("/v1/traces/recent", readCORS(v2ReadHandler.RecentTraces)) + // ServeMux chooses the longest matching pattern, so these prefix handlers + // do not capture the concrete routes above or /v1/events/validate. + mux.Handle("/v1/events/", readCORS(v2ReadHandler.EventByID)) + mux.Handle("/v1/traces/", readCORS(v2ReadHandler.TraceByID)) + slog.Info("v2 read endpoints enabled") + } else { + mux.Handle("/v1/traces/story", readCORS(ingestServer.TraceStory)) + mux.Handle("/v1/blast_radius", readCORS(ingestServer.BlastRadius)) + mux.Handle("/v1/traces/recent", readCORS(ingestServer.RecentTraces)) + mux.Handle("/v1/events/search", readCORS(ingestServer.EventSearch)) + } mux.Handle("/v1/overview/timeseries", readCORS(ingestServer.OverviewTimeseries)) mux.Handle("/v1/routes", readCORS(ingestServer.Routes)) mux.Handle("/v1/capabilities", readCORS(ingestServer.Capabilities)) mux.Handle("/v1/topology", readCORS(ingestServer.Topology)) - mux.Handle("/v1/blast_radius", readCORS(ingestServer.BlastRadius)) mux.Handle("/v1/stream/dashboard", readCORS(ingestServer.SSEStream)) mux.Handle("/v1/insight", readCORS(ingestServer.Insight)) diff --git a/docs/env.md b/docs/env.md index edbb2e6..745988c 100644 --- a/docs/env.md +++ b/docs/env.md @@ -47,6 +47,7 @@ Scoped keys. See the Auth section of the [README](../README.md). | `EVENT_LOG_MAX_FILE_MB` | `50` | Rotation size. `0` disables rotation | | `EVENT_LOG_RETENTION` | `72h` | Event log retention. Must be positive | | `WAYLOG_V2_DEDUP_CAPACITY` | `65536` | Recent schema-2.0 `event_id` dedupe cache capacity | +| `GRAPH_HOT_WINDOW` | `GRAPH_RETENTION` or `24h` | Recent in-memory graph/index retention window and max v2 read window | | `GRAPH_RETENTION` | `24h` | Hot graph retention. Nodes older than this are pruned every snapshot tick | See [Internals](internals.md) for the full durability model. @@ -63,6 +64,7 @@ See [Internals](internals.md) for the full durability model. | Variable | Default | Purpose | |---|---|---| | `GRAPH_UI` | `false` | Enable Graph topology tab in dashboard and `/v1/topology` endpoint | +| `WAYLOG_V2_READS` | `false` | Route v2 read endpoints to the schema-2.0 recent index | | `CAUSAL_ENABLED` | `false` | Enable shadow-mode causal inference | | `CAUSAL_INTERVAL` | `30s` | Causal inference ticker interval | | `HAPPY_SAMPLE_RATE_PCT` | `2` | Success-event sampling rate. Set `100` in dev profiles | diff --git a/internal/ingest/v2/anchor.go b/internal/ingest/v2/anchor.go new file mode 100644 index 0000000..a09e789 --- /dev/null +++ b/internal/ingest/v2/anchor.go @@ -0,0 +1,160 @@ +package ingestv2 + +import ( + "sort" + "strings" + + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +type AnchorResult struct { + Event *eventv2.Event + Linkage string +} + +type ResolveOpts struct { + ExcludeSuppressed bool +} + +func ResolveAnchor(events []*eventv2.Event) AnchorResult { + return ResolveAnchorWithOptions(events, ResolveOpts{}) +} + +func ResolveAnchorWithOptions(events []*eventv2.Event, opts ResolveOpts) AnchorResult { + if len(events) == 0 { + return AnchorResult{Linkage: LinkageTimestampFallback} + } + + failed := make([]*eventv2.Event, 0, len(events)) + for _, ev := range events { + if anchorCandidate(ev, opts) && isFailedStatus(ev.Status) { + failed = append(failed, ev) + } + } + if len(failed) == 0 { + ev, linkage := selectTraceRoot(events, opts) + return AnchorResult{Event: ev, Linkage: linkage} + } + + graph := buildTraceGraph(events) + if graph.usable && graphTouchesFailed(graph, failed) { + leaf := deepestFailedLeaf(graph, failed) + if leaf != nil { + return AnchorResult{Event: leaf, Linkage: LinkageCausal} + } + } + + sortEventsStable(failed) + return AnchorResult{Event: failed[0], Linkage: LinkageTimestampFallback} +} + +func selectTraceRoot(events []*eventv2.Event, opts ResolveOpts) (*eventv2.Event, string) { + candidates := make([]*eventv2.Event, 0, len(events)) + for _, ev := range events { + if anchorCandidate(ev, opts) && ev.ParentSpanID == "" { + candidates = append(candidates, ev) + } + } + linkage := LinkageTimestampFallback + if len(candidates) == 0 { + graph := buildTraceGraph(events) + for _, ev := range events { + if anchorCandidate(ev, opts) && ev.ParentSpanID != "" && graph.parents[ev.EventID] == "" { + candidates = append(candidates, ev) + } + } + if graph.usable { + linkage = LinkageCausal + } + } + if len(candidates) == 0 { + for _, ev := range events { + if anchorCandidate(ev, opts) { + candidates = append(candidates, ev) + } + } + } + sortEventsStable(candidates) + if len(candidates) == 0 { + return nil, linkage + } + return candidates[0], linkage +} + +func anchorCandidate(ev *eventv2.Event, opts ResolveOpts) bool { + if ev == nil { + return false + } + return !opts.ExcludeSuppressed || ev.Status != eventv2.StatusSuppressed +} + +func graphTouchesFailed(graph traceGraph, failed []*eventv2.Event) bool { + for _, ev := range failed { + if ev == nil { + continue + } + if graph.parents[ev.EventID] != "" || len(graph.children[ev.EventID]) > 0 { + return true + } + } + return false +} + +func deepestFailedLeaf(graph traceGraph, failed []*eventv2.Event) *eventv2.Event { + failedIDs := map[string]struct{}{} + for _, ev := range failed { + if ev != nil { + failedIDs[ev.EventID] = struct{}{} + } + } + leaves := make([]*eventv2.Event, 0, len(failed)) + for _, ev := range failed { + if ev == nil || hasFailedDescendant(graph, ev.EventID, failedIDs, map[string]struct{}{}) { + continue + } + leaves = append(leaves, ev) + } + if len(leaves) == 0 { + return nil + } + sort.SliceStable(leaves, func(i, j int) bool { + di := graph.depths[leaves[i].EventID] + dj := graph.depths[leaves[j].EventID] + if di != dj { + return di > dj + } + return compareEventIdentity(leaves[i], leaves[j]) < 0 + }) + return leaves[0] +} + +func hasFailedDescendant(graph traceGraph, eventID string, failed map[string]struct{}, seen map[string]struct{}) bool { + if _, ok := seen[eventID]; ok { + return false + } + seen[eventID] = struct{}{} + for _, child := range graph.children[eventID] { + if child == nil { + continue + } + if _, ok := failed[child.EventID]; ok { + return true + } + if hasFailedDescendant(graph, child.EventID, failed, seen) { + return true + } + } + return false +} + +func anchorSummary(ev *eventv2.Event) *string { + if ev == nil || ev.Anchor == nil { + return nil + } + s := escapeAnchorPart(ev.Service) + ":" + escapeAnchorPart(ev.Anchor.Step) + ":" + escapeAnchorPart(ev.Anchor.ErrorCode) + return &s +} + +func escapeAnchorPart(s string) string { + return strings.ReplaceAll(s, ":", `\:`) +} diff --git a/internal/ingest/v2/cursor.go b/internal/ingest/v2/cursor.go new file mode 100644 index 0000000..598c022 --- /dev/null +++ b/internal/ingest/v2/cursor.go @@ -0,0 +1,127 @@ +package ingestv2 + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" +) + +var errInvalidCursor = errors.New("invalid cursor") + +type EventCursor struct { + TsNano int64 `json:"t"` + EventID string `json:"e"` +} + +type TraceCursor struct { + TsNano int64 `json:"t"` + TraceID string `json:"r"` +} + +type ErrorCursor struct { + Count int `json:"c"` + Service string `json:"s"` + Step string `json:"p"` + ErrorCode string `json:"e"` +} + +func EncodeEventCursor(c EventCursor) (string, error) { + return encodeCursor(c) +} + +func DecodeEventCursor(s string) (EventCursor, error) { + var c EventCursor + if err := decodeCursor(s, &c); err != nil { + return EventCursor{}, err + } + if c.TsNano < 0 || c.EventID == "" { + return EventCursor{}, errInvalidCursor + } + return c, nil +} + +func EncodeTraceCursor(c TraceCursor) (string, error) { + return encodeCursor(c) +} + +func DecodeTraceCursor(s string) (TraceCursor, error) { + var c TraceCursor + if err := decodeCursor(s, &c); err != nil { + return TraceCursor{}, err + } + if c.TsNano < 0 || c.TraceID == "" { + return TraceCursor{}, errInvalidCursor + } + return c, nil +} + +func EncodeErrorCursor(c ErrorCursor) (string, error) { + return encodeCursor(c) +} + +func DecodeErrorCursor(s string) (ErrorCursor, error) { + var c ErrorCursor + if err := decodeCursor(s, &c); err != nil { + return ErrorCursor{}, err + } + if c.Count < 0 || c.Service == "" || c.Step == "" || c.ErrorCode == "" { + return ErrorCursor{}, errInvalidCursor + } + return c, nil +} + +func encodeCursor(v any) (string, error) { + raw, err := json.Marshal(v) + if err != nil { + return "", fmt.Errorf("encode cursor: %w", err) + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +func decodeCursor(s string, out any) error { + if s == "" { + return errInvalidCursor + } + raw, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + raw, err = base64.URLEncoding.DecodeString(s) + } + if err != nil { + return errInvalidCursor + } + if err := json.Unmarshal(raw, out); err != nil { + return errInvalidCursor + } + return nil +} + +func afterEventCursor(evTsNano int64, eventID string, cursor *EventCursor) bool { + if cursor == nil { + return true + } + return evTsNano < cursor.TsNano || (evTsNano == cursor.TsNano && eventID > cursor.EventID) +} + +func afterTraceCursor(tsNano int64, traceID string, cursor *TraceCursor) bool { + if cursor == nil { + return true + } + return tsNano < cursor.TsNano || (tsNano == cursor.TsNano && traceID > cursor.TraceID) +} + +func afterErrorCursor(count int, key ErrorKey, cursor *ErrorCursor) bool { + if cursor == nil { + return true + } + if count != cursor.Count { + return count < cursor.Count + } + if key.Service != cursor.Service { + return key.Service > cursor.Service + } + if key.Step != cursor.Step { + return key.Step > cursor.Step + } + return key.ErrorCode > cursor.ErrorCode +} diff --git a/internal/ingest/v2/cursor_test.go b/internal/ingest/v2/cursor_test.go new file mode 100644 index 0000000..cd53637 --- /dev/null +++ b/internal/ingest/v2/cursor_test.go @@ -0,0 +1,39 @@ +package ingestv2 + +import "testing" + +func TestEventCursorRoundTripAndContinuation(t *testing.T) { + encoded, err := EncodeEventCursor(EventCursor{TsNano: 100, EventID: "b"}) + if err != nil { + t.Fatal(err) + } + decoded, err := DecodeEventCursor(encoded) + if err != nil { + t.Fatal(err) + } + if decoded.TsNano != 100 || decoded.EventID != "b" { + t.Fatalf("decoded=%+v", decoded) + } + if afterEventCursor(101, "a", &decoded) { + t.Fatal("newer event should be before cursor") + } + if afterEventCursor(100, "a", &decoded) { + t.Fatal("same timestamp lower event_id should be before cursor") + } + if !afterEventCursor(100, "c", &decoded) { + t.Fatal("same timestamp higher event_id should be after cursor") + } + if !afterEventCursor(99, "a", &decoded) { + t.Fatal("older timestamp should be after cursor") + } +} + +func TestTraceCursorRejectsMalformed(t *testing.T) { + for _, raw := range []string{"", "not-base64", "bm90LWpzb24", "eyJ0IjotMX0"} { + t.Run(raw, func(t *testing.T) { + if _, err := DecodeTraceCursor(raw); err == nil { + t.Fatal("expected error") + } + }) + } +} diff --git a/internal/ingest/v2/derived.go b/internal/ingest/v2/derived.go new file mode 100644 index 0000000..afc40eb --- /dev/null +++ b/internal/ingest/v2/derived.go @@ -0,0 +1,537 @@ +package ingestv2 + +import ( + "sort" + "time" + + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +const LinkageDirect = "direct" + +type StoryResponse struct { + TraceID string `json:"trace_id"` + Service string `json:"service"` + Route string `json:"route"` + Status eventv2.Status `json:"status"` + Anchor *StoryAnchor `json:"anchor"` + Path []StoryStep `json:"path"` + Logs []StoryLog `json:"logs"` + Downstream []StoryDownstream `json:"downstream"` + Linkage string `json:"linkage"` +} + +type StoryAnchor struct { + Step string `json:"step"` + ErrorCode string `json:"error_code"` +} + +type StoryStep struct { + Name string `json:"name"` + StartMS int64 `json:"start_ms"` + DurationMS int64 `json:"duration_ms"` + Status string `json:"status"` + ErrorCode string `json:"error_code,omitempty"` + ErrorMsg string `json:"error_msg,omitempty"` +} + +type StoryLog struct { + TsOffsetMS int64 `json:"ts_offset_ms"` + Level string `json:"level"` + Msg string `json:"msg"` + Step string `json:"step,omitempty"` +} + +type StoryDownstream struct { + Step string `json:"step"` + Service string `json:"service"` + Endpoint string `json:"endpoint"` +} + +type ErrorFamily struct { + Service string `json:"service"` + Step string `json:"step"` + ErrorCode string `json:"error_code"` +} + +type ErrorRow struct { + ErrorFamily ErrorFamily `json:"error_family"` + Count int `json:"count"` + AffectedUsers *int `json:"affected_users"` + AffectedTraces int `json:"affected_traces"` + SampleTraces []string `json:"sample_traces"` +} + +type ErrorsResult struct { + Window string + Rows []ErrorRow + NextCursor *ErrorCursor +} + +type BlastKey struct { + Service string `json:"service,omitempty"` + Step string `json:"step,omitempty"` + ErrorCode string `json:"error_code"` +} + +type BlastRadiusResult struct { + Key BlastKey `json:"key"` + ViewMode string `json:"view_mode"` + Window string `json:"window"` + AffectedRequests int `json:"affected_requests"` + AffectedUsers *int `json:"affected_users"` + AffectedServices int `json:"affected_services"` + TopServices []string `json:"top_services"` + SampleTraces []string `json:"sample_traces"` +} + +type BlastKeyMode struct { + Key BlastKey + CrossCode bool +} + +func (r *Reader) TraceStoryByEventID(eventID string) (StoryResponse, bool) { + ev, ok := r.GetEvent(eventID) + if !ok { + return StoryResponse{}, false + } + return buildStory(ev, LinkageDirect), true +} + +func (r *Reader) TraceStoryByTraceID(traceID string) (StoryResponse, bool) { + if r == nil || r.index == nil || traceID == "" { + return StoryResponse{}, false + } + events := r.index.SnapshotTrace(traceID) + if len(events) == 0 { + return StoryResponse{}, false + } + resolved := ResolveAnchorWithOptions(events, ResolveOpts{ExcludeSuppressed: true}) + if resolved.Event == nil { + return StoryResponse{}, false + } + return buildStory(cloneEvent(resolved.Event), resolved.Linkage), true +} + +func (r *Reader) Errors(f SearchFilter, after *ErrorCursor, limit int) ErrorsResult { + if r == nil || r.index == nil || limit <= 0 { + return ErrorsResult{} + } + events := r.index.SnapshotEvents() + traces := r.index.SnapshotTraces() + rows := aggregateErrorRows(events, traces, f) + sort.SliceStable(rows, func(i, j int) bool { + return compareErrorRows(rows[i], rows[j]) < 0 + }) + out := make([]ErrorRow, 0, limit) + hasMore := false + for _, row := range rows { + key := ErrorKey{Service: row.ErrorFamily.Service, Step: row.ErrorFamily.Step, ErrorCode: row.ErrorFamily.ErrorCode} + if !afterErrorCursor(row.Count, key, after) { + continue + } + if len(out) == limit { + hasMore = true + break + } + out = append(out, row) + } + var next *ErrorCursor + if hasMore && len(out) > 0 { + last := out[len(out)-1] + next = &ErrorCursor{ + Count: last.Count, + Service: last.ErrorFamily.Service, + Step: last.ErrorFamily.Step, + ErrorCode: last.ErrorFamily.ErrorCode, + } + } + return ErrorsResult{Window: timeWindowString(f.Since, f.Until), Rows: out, NextCursor: next} +} + +func (r *Reader) BlastRadius(f SearchFilter, key BlastKeyMode) BlastRadiusResult { + if r == nil || r.index == nil { + return BlastRadiusResult{Key: key.Key, Window: timeWindowString(f.Since, f.Until), TopServices: []string{}, SampleTraces: []string{}} + } + events := r.index.SnapshotEvents() + traces := r.index.SnapshotTraces() + matchedTraceLatest := map[string]time.Time{} + for _, ev := range events { + if !eventMatchesBlastKey(ev, f, key) { + continue + } + if latest, ok := matchedTraceLatest[ev.TraceID]; !ok || ev.TsStart.After(latest) { + matchedTraceLatest[ev.TraceID] = ev.TsStart + } + } + users := collectUsersForTraces(traces, matchedTraceLatest) + services := countServicesForTraces(traces, matchedTraceLatest) + affectedUsers := nullableCount(len(users), len(users) > 0) + viewMode := "single_family" + if key.CrossCode { + viewMode = "cross_family" + } + return BlastRadiusResult{ + Key: key.Key, + ViewMode: viewMode, + Window: timeWindowString(f.Since, f.Until), + AffectedRequests: len(matchedTraceLatest), + AffectedUsers: affectedUsers, + AffectedServices: len(services), + TopServices: topServices(services, 10), + SampleTraces: sampleTraces(matchedTraceLatest, 3), + } +} + +func buildStory(ev *eventv2.Event, linkage string) StoryResponse { + out := StoryResponse{ + TraceID: ev.TraceID, + Service: ev.Service, + Route: routeFromV2Fields(ev.Fields), + Status: ev.Status, + Path: []StoryStep{}, + Logs: []StoryLog{}, + Downstream: []StoryDownstream{}, + Linkage: linkage, + } + if ev.Status == eventv2.StatusSuppressed { + return out + } + if ev.Anchor != nil { + out.Anchor = &StoryAnchor{Step: ev.Anchor.Step, ErrorCode: ev.Anchor.ErrorCode} + } + steps, anchorEnd, anchored := contributingSteps(ev) + for _, step := range steps { + out.Path = append(out.Path, storyStep(step)) + if step.Downstream != nil { + out.Downstream = append(out.Downstream, StoryDownstream{ + Step: step.Name, + Service: step.Downstream.Service, + Endpoint: step.Downstream.Endpoint, + }) + } + } + firstStart := int64(0) + if len(steps) > 0 { + firstStart = steps[0].StartMS + } + for _, log := range ev.Logs { + if log.Level != "warn" && log.Level != "error" { + continue + } + if anchored && (log.TsOffsetMS < firstStart || log.TsOffsetMS > anchorEnd) { + continue + } + out.Logs = append(out.Logs, StoryLog{TsOffsetMS: log.TsOffsetMS, Level: log.Level, Msg: log.Msg}) + } + return out +} + +func contributingSteps(ev *eventv2.Event) ([]eventv2.Step, int64, bool) { + steps := append([]eventv2.Step(nil), ev.Steps...) + if ev.Anchor == nil { + sortSteps(steps) + return steps, 0, false + } + anchorStep, ok := latestAnchorStep(steps, ev.Anchor.Step) + if !ok { + sortSteps(steps) + return steps, 0, false + } + anchorEnd := anchorStep.StartMS + anchorStep.DurationMS + out := make([]eventv2.Step, 0, len(steps)) + for _, step := range steps { + if step.StartMS+step.DurationMS <= anchorEnd { + out = append(out, step) + } + } + sortSteps(out) + return out, anchorEnd, true +} + +func latestAnchorStep(steps []eventv2.Step, name string) (eventv2.Step, bool) { + var fallback eventv2.Step + var latest eventv2.Step + hasFallback := false + hasLatest := false + for _, step := range steps { + if step.Name != name { + continue + } + if !hasFallback || step.StartMS > fallback.StartMS { + fallback = step + hasFallback = true + } + if step.Status == "error" && (!hasLatest || step.StartMS > latest.StartMS) { + latest = step + hasLatest = true + } + } + if hasLatest { + return latest, true + } + return fallback, hasFallback +} + +func sortSteps(steps []eventv2.Step) { + sort.SliceStable(steps, func(i, j int) bool { + if steps[i].StartMS != steps[j].StartMS { + return steps[i].StartMS < steps[j].StartMS + } + return i < j + }) +} + +func storyStep(step eventv2.Step) StoryStep { + out := StoryStep{Name: step.Name, StartMS: step.StartMS, DurationMS: step.DurationMS, Status: step.Status} + if step.Error != nil { + out.ErrorCode = step.Error.Code + out.ErrorMsg = step.Error.Reason + } + return out +} + +type errorAgg struct { + key ErrorKey + count int + traceLatest map[string]time.Time +} + +func aggregateErrorRows(events []*eventv2.Event, traces map[string][]*eventv2.Event, f SearchFilter) []ErrorRow { + aggs := map[ErrorKey]*errorAgg{} + for _, ev := range events { + if !eventMatchesErrorRollup(ev, f) { + continue + } + key := ErrorKey{Service: ev.Service, Step: ev.Anchor.Step, ErrorCode: ev.Anchor.ErrorCode} + agg := aggs[key] + if agg == nil { + agg = &errorAgg{key: key, traceLatest: map[string]time.Time{}} + aggs[key] = agg + } + agg.count++ + if latest, ok := agg.traceLatest[ev.TraceID]; !ok || ev.TsStart.After(latest) { + agg.traceLatest[ev.TraceID] = ev.TsStart + } + } + rows := make([]ErrorRow, 0, len(aggs)) + for _, agg := range aggs { + users := collectUsersForTraces(traces, agg.traceLatest) + rows = append(rows, ErrorRow{ + ErrorFamily: ErrorFamily{Service: agg.key.Service, Step: agg.key.Step, ErrorCode: agg.key.ErrorCode}, + Count: agg.count, + AffectedUsers: nullableCount(len(users), len(users) > 0), + AffectedTraces: len(agg.traceLatest), + SampleTraces: sampleTraces(agg.traceLatest, 3), + }) + } + return rows +} + +func eventMatchesErrorRollup(ev *eventv2.Event, f SearchFilter) bool { + if ev == nil || ev.Anchor == nil || !isFailedStatus(ev.Status) { + return false + } + if len(f.Statuses) > 0 { + if _, ok := f.Statuses[ev.Status]; !ok { + return false + } + } + if f.Service != "" && ev.Service != f.Service { + return false + } + return eventWithinWindow(ev, f) +} + +func eventMatchesBlastKey(ev *eventv2.Event, f SearchFilter, key BlastKeyMode) bool { + if ev == nil || ev.Anchor == nil || !isFailedStatus(ev.Status) || !eventWithinWindow(ev, f) { + return false + } + if key.Key.ErrorCode != ev.Anchor.ErrorCode { + return false + } + if key.CrossCode { + return true + } + return ev.Service == key.Key.Service && ev.Anchor.Step == key.Key.Step +} + +func eventWithinWindow(ev *eventv2.Event, f SearchFilter) bool { + if !f.Since.IsZero() && ev.TsStart.Before(f.Since) { + return false + } + if !f.Until.IsZero() && ev.TsStart.After(f.Until) { + return false + } + return true +} + +func compareErrorRows(a, b ErrorRow) int { + if a.Count != b.Count { + return b.Count - a.Count + } + if a.ErrorFamily.Service != b.ErrorFamily.Service { + if a.ErrorFamily.Service < b.ErrorFamily.Service { + return -1 + } + return 1 + } + if a.ErrorFamily.Step != b.ErrorFamily.Step { + if a.ErrorFamily.Step < b.ErrorFamily.Step { + return -1 + } + return 1 + } + if a.ErrorFamily.ErrorCode < b.ErrorFamily.ErrorCode { + return -1 + } + if a.ErrorFamily.ErrorCode > b.ErrorFamily.ErrorCode { + return 1 + } + return 0 +} + +func collectUsersForTraces(traces map[string][]*eventv2.Event, traceSet map[string]time.Time) map[string]struct{} { + users := map[string]struct{}{} + for traceID := range traceSet { + for _, ev := range traces[traceID] { + if userID := userIDFromFields(ev.Fields); userID != "" { + users[userID] = struct{}{} + } + } + } + return users +} + +func countServicesForTraces(traces map[string][]*eventv2.Event, traceSet map[string]time.Time) map[string]int { + services := map[string]int{} + for traceID := range traceSet { + seen := map[string]struct{}{} + for _, ev := range traces[traceID] { + if ev == nil || ev.Service == "" { + continue + } + seen[ev.Service] = struct{}{} + } + for service := range seen { + services[service]++ + } + } + return services +} + +func sampleTraces(traceLatest map[string]time.Time, limit int) []string { + type item struct { + traceID string + ts time.Time + } + items := make([]item, 0, len(traceLatest)) + for traceID, ts := range traceLatest { + items = append(items, item{traceID: traceID, ts: ts}) + } + sort.SliceStable(items, func(i, j int) bool { + if !items[i].ts.Equal(items[j].ts) { + return items[i].ts.After(items[j].ts) + } + return items[i].traceID < items[j].traceID + }) + if len(items) > limit { + items = items[:limit] + } + out := make([]string, 0, len(items)) + for _, item := range items { + out = append(out, item.traceID) + } + return out +} + +func topServices(counts map[string]int, limit int) []string { + type item struct { + service string + count int + } + items := make([]item, 0, len(counts)) + for service, count := range counts { + items = append(items, item{service: service, count: count}) + } + sort.SliceStable(items, func(i, j int) bool { + if items[i].count != items[j].count { + return items[i].count > items[j].count + } + return items[i].service < items[j].service + }) + if len(items) > limit { + items = items[:limit] + } + out := make([]string, 0, len(items)) + for _, item := range items { + out = append(out, item.service) + } + return out +} + +func nullableCount(n int, known bool) *int { + if !known { + return nil + } + v := n + return &v +} + +func userIDFromFields(fields map[string]any) string { + user, ok := fields["user"].(map[string]any) + if !ok { + return "" + } + id, _ := user["id"].(string) + return id +} + +func routeFromV2Fields(fields map[string]any) string { + httpFields, ok := fields["http"].(map[string]any) + if !ok { + return "" + } + route, _ := httpFields["route"].(string) + return route +} + +func parseErrorFamilyDisplay(s string) (BlastKey, bool) { + parts := make([]string, 0, 3) + var current []rune + escaped := false + for _, r := range s { + if escaped { + if r != ':' && r != '\\' { + return BlastKey{}, false + } + current = append(current, r) + escaped = false + continue + } + switch r { + case '\\': + escaped = true + case ':': + parts = append(parts, string(current)) + current = current[:0] + default: + current = append(current, r) + } + } + if escaped { + return BlastKey{}, false + } + parts = append(parts, string(current)) + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return BlastKey{}, false + } + return BlastKey{Service: parts[0], Step: parts[1], ErrorCode: parts[2]}, true +} + +func timeWindowString(since, until time.Time) string { + if since.IsZero() || until.IsZero() || until.Before(since) { + return "" + } + return until.Sub(since).String() +} diff --git a/internal/ingest/v2/derived_test.go b/internal/ingest/v2/derived_test.go new file mode 100644 index 0000000..7c6be7a --- /dev/null +++ b/internal/ingest/v2/derived_test.go @@ -0,0 +1,192 @@ +package ingestv2 + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +func TestTraceStoryDirectSuppressedIsHeaderOnly(t *testing.T) { + idx := NewRecentIndex(nil) + ev := testTraceEvent("suppressed", "trace", "checkout", eventv2.StatusSuppressed, testTime(0)) + ev.Steps = []eventv2.Step{{Name: "hidden", StartMS: 0, DurationMS: 1, Status: "ok"}} + idx.Insert(ev) + + story, ok := NewReader(idx).TraceStoryByEventID("suppressed") + if !ok { + t.Fatal("story not found") + } + if story.Linkage != LinkageDirect || story.Status != eventv2.StatusSuppressed { + t.Fatalf("story=%+v", story) + } + if len(story.Path) != 0 || len(story.Logs) != 0 || len(story.Downstream) != 0 { + t.Fatalf("suppressed story should be header-only: %+v", story) + } +} + +func TestTraceStoryByTraceExcludesSuppressedAndBuildsContributingWindow(t *testing.T) { + idx := NewRecentIndex(nil) + suppressedRoot := testTraceEvent("suppressed-root", "trace", "gateway", eventv2.StatusSuppressed, testTime(0)) + okChild := testTraceEvent("ok-child", "trace", "checkout", eventv2.StatusOK, testTime(1)) + okChild.ParentSpanID = "missing" + okChild.Fields = map[string]any{"http": map[string]any{"route": "/buy"}} + okChild.Anchor = &eventv2.Anchor{Step: "pay", ErrorCode: "PMT_502"} + okChild.Steps = []eventv2.Step{ + {Name: "prepare", StartMS: 0, DurationMS: 5, Status: "ok"}, + {Name: "pay", StartMS: 10, DurationMS: 5, Status: "error", Error: &eventv2.StepError{Code: "PMT_502", Reason: "gateway"}}, + {Name: "pay", StartMS: 20, DurationMS: 5, Status: "error", Error: &eventv2.StepError{Code: "PMT_502", Reason: "final"}}, + {Name: "cleanup", StartMS: 30, DurationMS: 5, Status: "ok"}, + } + okChild.Logs = []eventv2.Log{ + {TsOffsetMS: 4, Level: "warn", Msg: "early"}, + {TsOffsetMS: 24, Level: "error", Msg: "anchor"}, + {TsOffsetMS: 31, Level: "error", Msg: "late"}, + } + idx.Insert(suppressedRoot) + idx.Insert(okChild) + + story, ok := NewReader(idx).TraceStoryByTraceID("trace") + if !ok { + t.Fatal("story not found") + } + if story.TraceID != "trace" || story.Service != "checkout" || story.Route != "/buy" || story.Linkage != LinkageTimestampFallback { + t.Fatalf("story=%+v", story) + } + if story.Anchor == nil || story.Anchor.ErrorCode != "PMT_502" { + t.Fatalf("anchor=%+v", story.Anchor) + } + if len(story.Path) != 3 || story.Path[2].ErrorMsg != "final" { + t.Fatalf("path=%+v", story.Path) + } + if len(story.Logs) != 2 || story.Logs[1].Msg != "anchor" { + t.Fatalf("logs=%+v", story.Logs) + } +} + +func TestErrorsGroupsAndPaginates(t *testing.T) { + idx := NewRecentIndex(nil) + idx.Insert(errorEvent("a", "trace-a", "checkout", "charge", "PMT_502", testTime(3), "u1")) + idx.Insert(errorEvent("b", "trace-b", "checkout", "charge", "PMT_502", testTime(2), "u2")) + idx.Insert(errorEvent("c", "trace-c", "checkout", "reserve", "INV_409", testTime(1), "")) + + reader := NewReader(idx) + filter := SearchFilter{Since: testTime(0), Until: testTime(10)} + result := reader.Errors(filter, nil, 1) + if len(result.Rows) != 1 || result.Rows[0].Count != 2 || result.Rows[0].AffectedTraces != 2 { + t.Fatalf("result=%+v", result) + } + if result.Rows[0].AffectedUsers == nil || *result.Rows[0].AffectedUsers != 2 { + t.Fatalf("affected_users=%v", result.Rows[0].AffectedUsers) + } + if got := stringsJoin(result.Rows[0].SampleTraces); got != "trace-a,trace-b" { + t.Fatalf("sample_traces=%s", got) + } + if result.NextCursor == nil { + t.Fatal("missing cursor") + } + result = reader.Errors(filter, result.NextCursor, 10) + if len(result.Rows) != 1 || result.Rows[0].ErrorFamily.ErrorCode != "INV_409" || result.Rows[0].AffectedUsers != nil { + t.Fatalf("page2=%+v", result) + } +} + +func TestBlastRadiusKeyModesAndCounts(t *testing.T) { + idx := NewRecentIndex(nil) + idx.Insert(errorEvent("a", "trace-a", "checkout", "charge", "PMT_502", testTime(3), "u1")) + idx.Insert(errorEvent("b", "trace-b", "payment", "charge", "PMT_502", testTime(2), "u2")) + idx.Insert(testTraceEvent("ok", "trace-a", "cart", eventv2.StatusOK, testTime(4))) + + reader := NewReader(idx) + filter := SearchFilter{Since: testTime(0), Until: testTime(10)} + single := reader.BlastRadius(filter, BlastKeyMode{Key: BlastKey{Service: "checkout", Step: "charge", ErrorCode: "PMT_502"}}) + if single.ViewMode != "single_family" || single.AffectedRequests != 1 || single.AffectedServices != 2 { + t.Fatalf("single=%+v", single) + } + if single.AffectedUsers == nil || *single.AffectedUsers != 1 { + t.Fatalf("single users=%v", single.AffectedUsers) + } + if got := stringsJoin(single.TopServices); got != "cart,checkout" { + t.Fatalf("top_services=%s", got) + } + + cross := reader.BlastRadius(filter, BlastKeyMode{Key: BlastKey{ErrorCode: "PMT_502"}, CrossCode: true}) + if cross.ViewMode != "cross_family" || cross.AffectedRequests != 2 || cross.AffectedServices != 3 { + t.Fatalf("cross=%+v", cross) + } +} + +func TestReadHandlerDerivedEndpoints(t *testing.T) { + h := newTestReadHandler(t, nil) + h.reader.index.Insert(errorEvent("a", "trace-a", "checkout", "charge", "PMT_502", testTime(3), "u1")) + + rec := readGet(t, h.TraceStory, "/v1/traces/story") + expectReadError(t, rec, http.StatusBadRequest, errorCodeBadRequest) + + rec = readGet(t, h.TraceStory, "/v1/traces/story?trace_id=trace-a") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var story StoryResponse + if err := json.Unmarshal(rec.Body.Bytes(), &story); err != nil { + t.Fatal(err) + } + if story.TraceID != "trace-a" || story.Linkage != LinkageTimestampFallback { + t.Fatalf("story=%+v", story) + } + + rec = readGet(t, h.Errors, "/v1/errors?status=ok") + expectReadError(t, rec, http.StatusBadRequest, errorCodeBadRequest) + + since := testTime(0).Format(time.RFC3339Nano) + rec = readGet(t, h.Errors, "/v1/errors?since="+since) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var errorsBody errorsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &errorsBody); err != nil { + t.Fatal(err) + } + if len(errorsBody.Rows) != 1 || errorsBody.NextCursor != nil { + t.Fatalf("errors=%+v", errorsBody) + } + + rec = readGet(t, h.BlastRadius, `/v1/blast_radius?since=`+since+`&error_family=checkout:charge:PMT_502`) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var blast BlastRadiusResult + if err := json.Unmarshal(rec.Body.Bytes(), &blast); err != nil { + t.Fatal(err) + } + if blast.ViewMode != "single_family" || blast.Key.Service != "checkout" || blast.AffectedRequests != 1 { + t.Fatalf("blast=%+v", blast) + } + + rec = readGet(t, h.BlastRadius, `/v1/blast_radius?since=`+since+`&service=checkout&error_code=PMT_502`) + expectReadError(t, rec, http.StatusBadRequest, errorCodeBadRequest) +} + +func TestErrorFamilyDisplayParser(t *testing.T) { + key, ok := parseErrorFamilyDisplay(`svc\:a:step\:b:CODE`) + if !ok || key.Service != "svc:a" || key.Step != "step:b" || key.ErrorCode != "CODE" { + t.Fatalf("key=%+v ok=%v", key, ok) + } + for _, raw := range []string{"a:b", `a:b:c\`, `a:b:c:d`, `a:\x:c`} { + if _, ok := parseErrorFamilyDisplay(raw); ok { + t.Fatalf("expected malformed: %q", raw) + } + } +} + +func errorEvent(id, traceID, service, step, code string, ts time.Time, userID string) *eventv2.Event { + ev := testTraceEvent(id, traceID, service, eventv2.StatusError, ts) + ev.Anchor = &eventv2.Anchor{Step: step, ErrorCode: code} + ev.Steps = []eventv2.Step{{Name: step, StartMS: 0, DurationMS: 10, Status: "error", Error: &eventv2.StepError{Code: code, Reason: "failed"}}} + if userID != "" { + ev.Fields = map[string]any{"user": map[string]any{"id": userID}} + } + return ev +} diff --git a/internal/ingest/v2/index.go b/internal/ingest/v2/index.go index 893291a..b9b9d5f 100644 --- a/internal/ingest/v2/index.go +++ b/internal/ingest/v2/index.go @@ -119,6 +119,41 @@ func (idx *RecentIndex) TraceEvents(traceID string) []*eventv2.Event { return out } +func (idx *RecentIndex) SnapshotEvents() []*eventv2.Event { + if idx == nil { + return nil + } + idx.mu.RLock() + defer idx.mu.RUnlock() + out := make([]*eventv2.Event, 0, len(idx.byID)) + for _, ev := range idx.byID { + out = append(out, ev) + } + return out +} + +func (idx *RecentIndex) SnapshotTrace(traceID string) []*eventv2.Event { + if idx == nil || traceID == "" { + return nil + } + idx.mu.RLock() + defer idx.mu.RUnlock() + return append([]*eventv2.Event(nil), idx.byTrace[traceID]...) +} + +func (idx *RecentIndex) SnapshotTraces() map[string][]*eventv2.Event { + if idx == nil { + return nil + } + idx.mu.RLock() + defer idx.mu.RUnlock() + out := make(map[string][]*eventv2.Event, len(idx.byTrace)) + for traceID, events := range idx.byTrace { + out[traceID] = append([]*eventv2.Event(nil), events...) + } + return out +} + func (idx *RecentIndex) Sizes() IndexSizes { if idx == nil { return IndexSizes{} diff --git a/internal/ingest/v2/linkage.go b/internal/ingest/v2/linkage.go new file mode 100644 index 0000000..b13c26f --- /dev/null +++ b/internal/ingest/v2/linkage.go @@ -0,0 +1,169 @@ +package ingestv2 + +import ( + "sort" + + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +const ( + LinkageCausal = "causal" + LinkageTimestampFallback = "timestamp_fallback" +) + +func OrderTrace(events []*eventv2.Event) ([]*eventv2.Event, string) { + if len(events) == 0 { + return nil, LinkageTimestampFallback + } + graph := buildTraceGraph(events) + if !graph.usable { + return orderByTimestamp(events), LinkageTimestampFallback + } + + roots := make([]*eventv2.Event, 0, len(events)) + for _, ev := range events { + if ev != nil && ev.ParentSpanID == "" { + roots = append(roots, ev) + } + } + sortEventsStable(roots) + + seen := map[string]struct{}{} + ordered := make([]*eventv2.Event, 0, len(events)) + var walk func(*eventv2.Event) + walk = func(ev *eventv2.Event) { + if ev == nil { + return + } + if _, ok := seen[ev.EventID]; ok { + return + } + seen[ev.EventID] = struct{}{} + ordered = append(ordered, ev) + children := append([]*eventv2.Event(nil), graph.children[ev.EventID]...) + sortEventsStable(children) + for _, child := range children { + walk(child) + } + } + for _, root := range roots { + walk(root) + } + if len(ordered) != len(events) { + return orderByTimestamp(events), LinkageTimestampFallback + } + return ordered, LinkageCausal +} + +type traceGraph struct { + children map[string][]*eventv2.Event + parents map[string]string + depths map[string]int + usable bool +} + +func buildTraceGraph(events []*eventv2.Event) traceGraph { + byEventID := map[string]*eventv2.Event{} + spanOwner := map[string]*eventv2.Event{} + for _, ev := range events { + if ev == nil || ev.EventID == "" { + continue + } + byEventID[ev.EventID] = ev + for _, step := range ev.Steps { + if step.SpanID != "" { + spanOwner[step.SpanID] = ev + } + } + } + + g := traceGraph{ + children: map[string][]*eventv2.Event{}, + parents: map[string]string{}, + depths: map[string]int{}, + } + edgeCount := 0 + for _, child := range events { + if child == nil || child.EventID == "" || child.ParentSpanID == "" { + continue + } + parent := spanOwner[child.ParentSpanID] + if parent == nil || parent.EventID == child.EventID { + return g + } + g.children[parent.EventID] = append(g.children[parent.EventID], child) + g.parents[child.EventID] = parent.EventID + edgeCount++ + } + if edgeCount == 0 { + return g + } + + var depthOf func(string, map[string]struct{}) int + depthOf = func(eventID string, stack map[string]struct{}) int { + if d, ok := g.depths[eventID]; ok { + return d + } + parentID := g.parents[eventID] + if parentID == "" { + g.depths[eventID] = 0 + return 0 + } + if _, cyclic := stack[eventID]; cyclic { + return 0 + } + stack[eventID] = struct{}{} + d := depthOf(parentID, stack) + 1 + delete(stack, eventID) + g.depths[eventID] = d + return d + } + for eventID := range byEventID { + depthOf(eventID, map[string]struct{}{}) + } + g.usable = true + return g +} + +func orderByTimestamp(events []*eventv2.Event) []*eventv2.Event { + out := append([]*eventv2.Event(nil), events...) + sortEventsStable(out) + return out +} + +func sortEventsStable(events []*eventv2.Event) { + sort.SliceStable(events, func(i, j int) bool { + return compareEventIdentity(events[i], events[j]) < 0 + }) +} + +func compareEventIdentity(a, b *eventv2.Event) int { + if a == nil && b == nil { + return 0 + } + if a == nil { + return 1 + } + if b == nil { + return -1 + } + if !a.TsStart.Equal(b.TsStart) { + if a.TsStart.Before(b.TsStart) { + return -1 + } + return 1 + } + if a.Service != b.Service { + if a.Service < b.Service { + return -1 + } + return 1 + } + if a.EventID < b.EventID { + return -1 + } + if a.EventID > b.EventID { + return 1 + } + return 0 +} diff --git a/internal/ingest/v2/linkage_test.go b/internal/ingest/v2/linkage_test.go new file mode 100644 index 0000000..7a5bead --- /dev/null +++ b/internal/ingest/v2/linkage_test.go @@ -0,0 +1,151 @@ +package ingestv2 + +import ( + "testing" + "time" + + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +func TestOrderTraceCausalDepthFirst(t *testing.T) { + root := testTraceEvent("root", "trace", "gateway", eventv2.StatusOK, testTime(0)) + root.Steps = []eventv2.Step{{Name: "call.checkout", SpanID: "span-checkout", Status: "ok"}} + child := testTraceEvent("child", "trace", "checkout", eventv2.StatusOK, testTime(1)) + child.ParentSpanID = "span-checkout" + child.Steps = []eventv2.Step{{Name: "call.payment", SpanID: "span-payment", Status: "ok"}} + leaf := testTraceEvent("leaf", "trace", "payment", eventv2.StatusOK, testTime(2)) + leaf.ParentSpanID = "span-payment" + + ordered, linkage := OrderTrace([]*eventv2.Event{leaf, child, root}) + if linkage != LinkageCausal { + t.Fatalf("linkage=%s", linkage) + } + if ids(ordered) != "root,child,leaf" { + t.Fatalf("order=%s", ids(ordered)) + } +} + +func TestOrderTracePartialLinkageFallsBack(t *testing.T) { + root := testTraceEvent("root", "trace", "gateway", eventv2.StatusOK, testTime(2)) + root.Steps = []eventv2.Step{{Name: "payment.charge", Status: "ok"}} + child := testTraceEvent("child", "trace", "payment", eventv2.StatusOK, testTime(1)) + child.ParentSpanID = "missing" + + ordered, linkage := OrderTrace([]*eventv2.Event{root, child}) + if linkage != LinkageTimestampFallback { + t.Fatalf("linkage=%s", linkage) + } + if ids(ordered) != "child,root" { + t.Fatalf("order=%s", ids(ordered)) + } +} + +func TestResolveAnchorDeepestFailedLeaf(t *testing.T) { + root := testTraceEvent("root", "trace", "gateway", eventv2.StatusError, testTime(0)) + root.Anchor = &eventv2.Anchor{Step: "call.checkout", ErrorCode: "GATEWAY"} + root.Steps = []eventv2.Step{{Name: "call.checkout", SpanID: "span-checkout", Status: "error"}} + child := testTraceEvent("child", "trace", "checkout", eventv2.StatusError, testTime(1)) + child.ParentSpanID = "span-checkout" + child.Anchor = &eventv2.Anchor{Step: "call.payment", ErrorCode: "CHECKOUT"} + child.Steps = []eventv2.Step{{Name: "call.payment", SpanID: "span-payment", Status: "error"}} + leaf := testTraceEvent("leaf", "trace", "payment", eventv2.StatusError, testTime(2)) + leaf.ParentSpanID = "span-payment" + leaf.Anchor = &eventv2.Anchor{Step: "charge", ErrorCode: "PAYMENT"} + + result := ResolveAnchor([]*eventv2.Event{root, leaf, child}) + if result.Linkage != LinkageCausal || result.Event.EventID != "leaf" { + t.Fatalf("result=%+v", result) + } +} + +func TestResolveAnchorFallbackDoesNotParseStepName(t *testing.T) { + root := testTraceEvent("root", "trace", "gateway", eventv2.StatusOK, testTime(0)) + root.Steps = []eventv2.Step{{Name: "payment.charge", Status: "ok"}} + payment := testTraceEvent("payment", "trace", "payment", eventv2.StatusError, testTime(1)) + payment.Anchor = &eventv2.Anchor{Step: "charge", ErrorCode: "PAYMENT"} + + result := ResolveAnchor([]*eventv2.Event{payment, root}) + if result.Linkage != LinkageTimestampFallback || result.Event.EventID != "payment" { + t.Fatalf("result=%+v", result) + } +} + +func TestResolveAnchorNoFailuresRootAndSuppressedOnly(t *testing.T) { + root := testTraceEvent("root", "trace", "gateway", eventv2.StatusOK, testTime(0)) + child := testTraceEvent("child", "trace", "checkout", eventv2.StatusOK, testTime(1)) + child.ParentSpanID = "missing" + + result := ResolveAnchor([]*eventv2.Event{child, root}) + if result.Event.EventID != "root" { + t.Fatalf("root=%s", result.Event.EventID) + } + + suppressed := testTraceEvent("suppressed", "trace2", "gateway", eventv2.StatusSuppressed, testTime(0)) + result = ResolveAnchor([]*eventv2.Event{suppressed}) + if result.Event.EventID != "suppressed" || result.Event.Status != eventv2.StatusSuppressed { + t.Fatalf("suppressed result=%+v", result) + } +} + +func TestResolveAnchorOptionsExcludeSuppressed(t *testing.T) { + suppressedRoot := testTraceEvent("suppressed-root", "trace", "gateway", eventv2.StatusSuppressed, testTime(0)) + okChild := testTraceEvent("ok-child", "trace", "checkout", eventv2.StatusOK, testTime(1)) + okChild.ParentSpanID = "missing" + + result := ResolveAnchor([]*eventv2.Event{suppressedRoot, okChild}) + if result.Event.EventID != "suppressed-root" { + t.Fatalf("default root=%s", result.Event.EventID) + } + result = ResolveAnchorWithOptions([]*eventv2.Event{suppressedRoot, okChild}, ResolveOpts{ExcludeSuppressed: true}) + if result.Event.EventID != "ok-child" { + t.Fatalf("excluded root=%s", result.Event.EventID) + } + + result = ResolveAnchorWithOptions([]*eventv2.Event{suppressedRoot}, ResolveOpts{ExcludeSuppressed: true}) + if result.Event != nil { + t.Fatalf("result=%+v want nil event", result) + } +} + +func TestResolveAnchorUnsatisfiedParentRootBranch(t *testing.T) { + orphan := testTraceEvent("orphan", "trace", "gateway", eventv2.StatusOK, testTime(1)) + orphan.ParentSpanID = "missing" + late := testTraceEvent("late", "trace", "checkout", eventv2.StatusOK, testTime(2)) + late.ParentSpanID = "also-missing" + + result := ResolveAnchor([]*eventv2.Event{late, orphan}) + if result.Event.EventID != "orphan" || result.Linkage != LinkageTimestampFallback { + t.Fatalf("result=%+v", result) + } +} + +func testTime(seconds int) time.Time { + return time.Date(2026, 4, 25, 14, 0, seconds, 0, time.UTC) +} + +func testTraceEvent(id, traceID, service string, status eventv2.Status, ts time.Time) *eventv2.Event { + return &eventv2.Event{ + SchemaVersion: eventv2.SchemaVersion2, + EventID: id, + TsStart: ts, + TsEnd: ts.Add(10 * time.Millisecond), + DurationMS: 10, + Kind: "http", + Service: service, + Env: "test", + TraceID: traceID, + SpanID: id + "-span", + Status: status, + } +} + +func ids(events []*eventv2.Event) string { + out := "" + for i, ev := range events { + if i > 0 { + out += "," + } + out += ev.EventID + } + return out +} diff --git a/internal/ingest/v2/queryparams.go b/internal/ingest/v2/queryparams.go new file mode 100644 index 0000000..1b28f77 --- /dev/null +++ b/internal/ingest/v2/queryparams.go @@ -0,0 +1,148 @@ +package ingestv2 + +import ( + "fmt" + "net/url" + "strconv" + "strings" + "time" + + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +const ( + defaultListLimit = 50 + maxListLimit = 200 +) + +type queryError struct { + code string + message string + detail string +} + +func rejectUnknownParams(q url.Values, allowed map[string]struct{}) *queryError { + for key := range q { + if _, ok := allowed[key]; !ok { + return &queryError{code: errorCodeBadRequest, message: "bad request", detail: "unknown query parameter: " + key} + } + } + return nil +} + +func parseLimit(q url.Values) (int, *queryError) { + raw := q.Get("limit") + if raw == "" { + return defaultListLimit, nil + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return 0, &queryError{code: errorCodeBadRequest, message: "bad request", detail: "invalid limit"} + } + if n > maxListLimit { + return 0, &queryError{code: errorCodeOverLimit, message: "limit exceeds maximum", detail: fmt.Sprintf("limit must be <= %d", maxListLimit)} + } + return n, nil +} + +func parseIncludeSuppressed(q url.Values) (bool, *queryError) { + raw := q.Get("include_suppressed") + if raw == "" { + return false, nil + } + v, err := strconv.ParseBool(raw) + if err != nil { + return false, &queryError{code: errorCodeBadRequest, message: "bad request", detail: "invalid include_suppressed"} + } + return v, nil +} + +func parseStatusCSV(q url.Values) (map[eventv2.Status]struct{}, *queryError) { + raw := q.Get("status") + if raw == "" { + return nil, nil + } + out := map[eventv2.Status]struct{}{} + for _, part := range strings.Split(raw, ",") { + status := eventv2.Status(strings.TrimSpace(part)) + switch status { + case eventv2.StatusOK, eventv2.StatusError, eventv2.StatusTimeout, eventv2.StatusPartial, eventv2.StatusAborted, eventv2.StatusSuppressed: + out[status] = struct{}{} + default: + return nil, &queryError{code: errorCodeBadRequest, message: "bad request", detail: "invalid status"} + } + } + return out, nil +} + +func parseTimeWindow(q url.Values, now time.Time, defaultWindow, maxWindow time.Duration) (since, until time.Time, err *queryError) { + if defaultWindow <= 0 { + defaultWindow = time.Hour + } + if maxWindow <= 0 { + maxWindow = defaultWindow + } + + rawSince := q.Get("since") + rawUntil := q.Get("until") + if rawSince != "" || rawUntil != "" { + until = now + if rawUntil != "" { + parsed, parseErr := time.Parse(time.RFC3339Nano, rawUntil) + if parseErr != nil { + return time.Time{}, time.Time{}, &queryError{code: errorCodeBadRequest, message: "bad request", detail: "invalid until"} + } + until = parsed + } + if rawSince != "" { + parsed, parseErr := time.Parse(time.RFC3339Nano, rawSince) + if parseErr != nil { + return time.Time{}, time.Time{}, &queryError{code: errorCodeBadRequest, message: "bad request", detail: "invalid since"} + } + since = parsed + if since.Before(now.Add(-maxWindow)) { + return time.Time{}, time.Time{}, &queryError{code: errorCodeOverLimit, message: "time window exceeds maximum", detail: "since is older than the hot window"} + } + } else { + since = until.Add(-maxWindow) + } + if since.After(until) { + return time.Time{}, time.Time{}, &queryError{code: errorCodeBadRequest, message: "bad request", detail: "since must be <= until"} + } + return since, until, nil + } + + window := defaultWindow + if raw := q.Get("window"); raw != "" { + parsed, parseErr := parseReadDuration(raw) + if parseErr != nil || parsed <= 0 { + return time.Time{}, time.Time{}, &queryError{code: errorCodeBadRequest, message: "bad request", detail: "invalid window"} + } + window = parsed + } + if window > maxWindow { + return time.Time{}, time.Time{}, &queryError{code: errorCodeOverLimit, message: "time window exceeds maximum", detail: "window exceeds hot window"} + } + until = now + since = until.Add(-window) + return since, until, nil +} + +func parseReadDuration(raw string) (time.Duration, error) { + if strings.HasSuffix(raw, "d") { + days, err := strconv.Atoi(strings.TrimSuffix(raw, "d")) + if err != nil { + return 0, err + } + return time.Duration(days) * 24 * time.Hour, nil + } + return time.ParseDuration(raw) +} + +func statusIncludes(statuses map[eventv2.Status]struct{}, status eventv2.Status) bool { + if len(statuses) == 0 { + return false + } + _, ok := statuses[status] + return ok +} diff --git a/internal/ingest/v2/reader.go b/internal/ingest/v2/reader.go new file mode 100644 index 0000000..5758efd --- /dev/null +++ b/internal/ingest/v2/reader.go @@ -0,0 +1,266 @@ +package ingestv2 + +import ( + "sort" + "time" + + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +type Reader struct { + index *RecentIndex +} + +func NewReader(index *RecentIndex) *Reader { + return &Reader{index: index} +} + +type SearchFilter struct { + Service string + Statuses map[eventv2.Status]struct{} + ErrorCode string + TraceID string + Since time.Time + Until time.Time + IncludeSuppressed bool +} + +type EventSearchResult struct { + Events []*eventv2.Event + NextCursor *EventCursor +} + +type TraceGetResult struct { + TraceID string + Events []*eventv2.Event + Linkage string +} + +type TraceSummary struct { + TraceID string `json:"trace_id"` + TsStart time.Time `json:"ts_start"` + DurationMS int64 `json:"duration_ms"` + Services []string `json:"services"` + Status eventv2.Status `json:"status"` + AnchorSummary *string `json:"anchor_summary"` +} + +type RecentTracesResult struct { + Traces []TraceSummary + NextCursor *TraceCursor +} + +func (r *Reader) GetEvent(eventID string) (*eventv2.Event, bool) { + if r == nil || r.index == nil { + return nil, false + } + return r.index.GetByID(eventID) +} + +func (r *Reader) SearchEvents(f SearchFilter, after *EventCursor, limit int) EventSearchResult { + if r == nil || r.index == nil || limit <= 0 { + return EventSearchResult{} + } + events := r.index.SnapshotEvents() + filtered := make([]*eventv2.Event, 0, len(events)) + for _, ev := range events { + if eventMatchesFilter(ev, f) { + filtered = append(filtered, ev) + } + } + sort.SliceStable(filtered, func(i, j int) bool { + return compareEventSearchOrder(filtered[i], filtered[j]) < 0 + }) + + out := make([]*eventv2.Event, 0, limit) + hasMore := false + for _, ev := range filtered { + if ev == nil || !afterEventCursor(ev.TsStart.UnixNano(), ev.EventID, after) { + continue + } + if len(out) == limit { + hasMore = true + break + } + out = append(out, cloneEvent(ev)) + } + var next *EventCursor + if hasMore && len(out) > 0 { + last := out[len(out)-1] + next = &EventCursor{TsNano: last.TsStart.UnixNano(), EventID: last.EventID} + } + return EventSearchResult{Events: out, NextCursor: next} +} + +func (r *Reader) GetTrace(traceID string) (TraceGetResult, bool) { + if r == nil || r.index == nil || traceID == "" { + return TraceGetResult{}, false + } + events := r.index.SnapshotTrace(traceID) + if len(events) == 0 { + return TraceGetResult{}, false + } + ordered, linkage := OrderTrace(events) + out := make([]*eventv2.Event, 0, len(ordered)) + for _, ev := range ordered { + out = append(out, cloneEvent(ev)) + } + return TraceGetResult{TraceID: traceID, Events: out, Linkage: linkage}, true +} + +func (r *Reader) RecentTraces(f SearchFilter, after *TraceCursor, limit int) RecentTracesResult { + if r == nil || r.index == nil || limit <= 0 { + return RecentTracesResult{} + } + groups := r.index.SnapshotTraces() + summaries := make([]TraceSummary, 0, len(groups)) + for traceID, events := range groups { + if traceHasMatchingEvent(events, f) { + summary, ok := buildTraceSummary(traceID, events) + if ok { + summaries = append(summaries, summary) + } + } + } + sort.SliceStable(summaries, func(i, j int) bool { + if !summaries[i].TsStart.Equal(summaries[j].TsStart) { + return summaries[i].TsStart.After(summaries[j].TsStart) + } + return summaries[i].TraceID < summaries[j].TraceID + }) + + out := make([]TraceSummary, 0, limit) + hasMore := false + for _, summary := range summaries { + if !afterTraceCursor(summary.TsStart.UnixNano(), summary.TraceID, after) { + continue + } + if len(out) == limit { + hasMore = true + break + } + out = append(out, summary) + } + var next *TraceCursor + if hasMore && len(out) > 0 { + last := out[len(out)-1] + next = &TraceCursor{TsNano: last.TsStart.UnixNano(), TraceID: last.TraceID} + } + return RecentTracesResult{Traces: out, NextCursor: next} +} + +func eventMatchesFilter(ev *eventv2.Event, f SearchFilter) bool { + if ev == nil { + return false + } + if f.Service != "" && ev.Service != f.Service { + return false + } + if f.TraceID != "" && ev.TraceID != f.TraceID { + return false + } + if f.ErrorCode != "" { + if ev.Anchor == nil || ev.Anchor.ErrorCode != f.ErrorCode { + return false + } + } + if len(f.Statuses) > 0 { + if _, ok := f.Statuses[ev.Status]; !ok { + return false + } + } else if ev.Status == eventv2.StatusSuppressed && !f.IncludeSuppressed { + return false + } + if !f.Since.IsZero() && ev.TsStart.Before(f.Since) { + return false + } + if !f.Until.IsZero() && ev.TsStart.After(f.Until) { + return false + } + return true +} + +func traceHasMatchingEvent(events []*eventv2.Event, f SearchFilter) bool { + for _, ev := range events { + if eventMatchesFilter(ev, f) { + return true + } + } + return false +} + +func buildTraceSummary(traceID string, events []*eventv2.Event) (TraceSummary, bool) { + if len(events) == 0 { + return TraceSummary{}, false + } + ordered, _ := OrderTrace(events) + if len(ordered) == 0 { + return TraceSummary{}, false + } + var minStart, maxEnd time.Time + services := make([]string, 0, len(ordered)) + seenServices := map[string]struct{}{} + for _, ev := range events { + if ev == nil { + continue + } + if minStart.IsZero() || ev.TsStart.Before(minStart) { + minStart = ev.TsStart + } + if maxEnd.IsZero() || ev.TsEnd.After(maxEnd) { + maxEnd = ev.TsEnd + } + } + for _, ev := range ordered { + if ev == nil || ev.Service == "" { + continue + } + if _, ok := seenServices[ev.Service]; ok { + continue + } + seenServices[ev.Service] = struct{}{} + services = append(services, ev.Service) + } + resolved := ResolveAnchor(events) + status := eventv2.Status("") + if resolved.Event != nil { + status = resolved.Event.Status + } + duration := int64(0) + if !minStart.IsZero() && !maxEnd.IsZero() && maxEnd.After(minStart) { + duration = maxEnd.Sub(minStart).Milliseconds() + } + return TraceSummary{ + TraceID: traceID, + TsStart: minStart, + DurationMS: duration, + Services: services, + Status: status, + AnchorSummary: anchorSummary(resolved.Event), + }, true +} + +func compareEventSearchOrder(a, b *eventv2.Event) int { + if a == nil && b == nil { + return 0 + } + if a == nil { + return 1 + } + if b == nil { + return -1 + } + if !a.TsStart.Equal(b.TsStart) { + if a.TsStart.After(b.TsStart) { + return -1 + } + return 1 + } + if a.EventID < b.EventID { + return -1 + } + if a.EventID > b.EventID { + return 1 + } + return 0 +} diff --git a/internal/ingest/v2/reader_test.go b/internal/ingest/v2/reader_test.go new file mode 100644 index 0000000..c616709 --- /dev/null +++ b/internal/ingest/v2/reader_test.go @@ -0,0 +1,114 @@ +package ingestv2 + +import ( + "testing" + "time" + + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +func TestSearchEventsFiltersAndSuppressedDefault(t *testing.T) { + idx := NewRecentIndex(nil) + idx.Insert(testTraceEvent("ok", "trace-a", "checkout", eventv2.StatusOK, testTime(0))) + errEvent := testTraceEvent("err", "trace-b", "payment", eventv2.StatusError, testTime(1)) + errEvent.Anchor = &eventv2.Anchor{Step: "charge", ErrorCode: "PMT_502"} + idx.Insert(errEvent) + idx.Insert(testTraceEvent("suppressed", "trace-c", "checkout", eventv2.StatusSuppressed, testTime(2))) + + reader := NewReader(idx) + result := reader.SearchEvents(SearchFilter{Since: testTime(-1), Until: testTime(10)}, nil, 10) + if got := ids(result.Events); got != "err,ok" { + t.Fatalf("default search=%s", got) + } + + result = reader.SearchEvents(SearchFilter{Service: "payment", ErrorCode: "PMT_502", Since: testTime(-1), Until: testTime(10)}, nil, 10) + if got := ids(result.Events); got != "err" { + t.Fatalf("filtered search=%s", got) + } + + result = reader.SearchEvents(SearchFilter{IncludeSuppressed: true, Since: testTime(-1), Until: testTime(10)}, nil, 10) + if got := ids(result.Events); got != "suppressed,err,ok" { + t.Fatalf("include suppressed=%s", got) + } +} + +func TestSearchEventsPaginatesWithCursor(t *testing.T) { + idx := NewRecentIndex(nil) + for i, id := range []string{"a", "b", "c", "d", "e"} { + idx.Insert(testTraceEvent(id, "trace", "svc", eventv2.StatusOK, testTime(i))) + } + reader := NewReader(idx) + filter := SearchFilter{Since: testTime(-1), Until: testTime(10)} + + page1 := reader.SearchEvents(filter, nil, 2) + if got := ids(page1.Events); got != "e,d" { + t.Fatalf("page1=%s", got) + } + page2 := reader.SearchEvents(filter, page1.NextCursor, 2) + if got := ids(page2.Events); got != "c,b" { + t.Fatalf("page2=%s", got) + } + page3 := reader.SearchEvents(filter, page2.NextCursor, 2) + if got := ids(page3.Events); got != "a" { + t.Fatalf("page3=%s", got) + } + if page3.NextCursor != nil { + t.Fatalf("next=%+v want nil", page3.NextCursor) + } +} + +func TestRecentTracesUsesAnyEventFilterAndFullTraceSummary(t *testing.T) { + idx := NewRecentIndex(nil) + root := testTraceEvent("root", "trace", "gateway", eventv2.StatusOK, testTime(0)) + root.Steps = []eventv2.Step{{Name: "call.payment", SpanID: "span-payment", Status: "ok"}} + payment := testTraceEvent("payment", "trace", "payment", eventv2.StatusError, testTime(1)) + payment.ParentSpanID = "span-payment" + payment.Anchor = &eventv2.Anchor{Step: "charge", ErrorCode: "PMT:502"} + idx.Insert(root) + idx.Insert(payment) + + reader := NewReader(idx) + result := reader.RecentTraces(SearchFilter{Service: "payment", Since: testTime(-1), Until: testTime(10)}, nil, 10) + if len(result.Traces) != 1 { + t.Fatalf("traces=%+v", result.Traces) + } + trace := result.Traces[0] + if trace.TraceID != "trace" || trace.Status != eventv2.StatusError { + t.Fatalf("trace=%+v", trace) + } + if trace.DurationMS != int64(time.Second/time.Millisecond)+10 { + t.Fatalf("duration=%d", trace.DurationMS) + } + if got := stringsJoin(trace.Services); got != "gateway,payment" { + t.Fatalf("services=%s", got) + } + if trace.AnchorSummary == nil || *trace.AnchorSummary != `payment:charge:PMT\:502` { + t.Fatalf("anchor=%v", trace.AnchorSummary) + } +} + +func TestRecentTracesSuppressedOnlyRequiresOptIn(t *testing.T) { + idx := NewRecentIndex(nil) + idx.Insert(testTraceEvent("suppressed", "trace", "gateway", eventv2.StatusSuppressed, testTime(0))) + reader := NewReader(idx) + filter := SearchFilter{Since: testTime(-1), Until: testTime(10)} + if got := reader.RecentTraces(filter, nil, 10); len(got.Traces) != 0 { + t.Fatalf("traces=%+v want empty", got.Traces) + } + filter.IncludeSuppressed = true + got := reader.RecentTraces(filter, nil, 10) + if len(got.Traces) != 1 || got.Traces[0].Status != eventv2.StatusSuppressed { + t.Fatalf("traces=%+v", got.Traces) + } +} + +func stringsJoin(parts []string) string { + out := "" + for i, part := range parts { + if i > 0 { + out += "," + } + out += part + } + return out +} diff --git a/internal/ingest/v2/readhandler.go b/internal/ingest/v2/readhandler.go new file mode 100644 index 0000000..79cbb59 --- /dev/null +++ b/internal/ingest/v2/readhandler.go @@ -0,0 +1,501 @@ +package ingestv2 + +import ( + "encoding/json" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sssmaran/WaylogCLI/internal/metrics" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +const ( + errorCodeBadRequest = "bad_request" + errorCodeNotFound = "not_found" + errorCodeOverLimit = "over_limit" + errorCodeValidationFailed = "validation_failed" + errorCodeUnavailable = "unavailable" + + readHandlerEventGet = "event_get" + readHandlerEventSearch = "event_search" + readHandlerTraceGet = "trace_get" + readHandlerTracesRecent = "traces_recent" + readHandlerTraceStory = "trace_story" + readHandlerErrors = "errors" + readHandlerBlastRadius = "blast_radius" +) + +type ReadHandler struct { + reader *Reader + metrics *metrics.Metrics + maxWindow time.Duration + now func() time.Time +} + +func NewReadHandler(reader *Reader, m *metrics.Metrics, maxWindow time.Duration) *ReadHandler { + return &ReadHandler{reader: reader, metrics: m, maxWindow: maxWindow, now: time.Now} +} + +func (h *ReadHandler) EventByID(w http.ResponseWriter, r *http.Request) { + h.observe(readHandlerEventGet, func() { + if !requireGET(w, r) { + return + } + if err := rejectUnknownParams(r.URL.Query(), nil); err != nil { + writeReadError(w, http.StatusBadRequest, err.code, err.message, err.detail) + return + } + eventID := singlePathTail(r.URL.Path, "/v1/events/") + if eventID == "" { + h.recordNotFound(readHandlerEventGet) + writeReadError(w, http.StatusNotFound, errorCodeNotFound, "event not found", "") + return + } + ev, ok := h.reader.GetEvent(eventID) + if !ok { + h.recordNotFound(readHandlerEventGet) + writeReadError(w, http.StatusNotFound, errorCodeNotFound, "event not found", "") + return + } + writeReadJSON(w, http.StatusOK, eventGetResponse{Event: ev}) + }) +} + +func (h *ReadHandler) EventSearch(w http.ResponseWriter, r *http.Request) { + h.observe(readHandlerEventSearch, func() { + if !requireGET(w, r) { + return + } + params, ok := h.parseSearchParams(w, r, time.Hour, allowedEventSearchParams, true) + if !ok { + return + } + result := h.reader.SearchEvents(params.Filter, params.EventCursor, params.Limit) + if len(result.Events) == 0 { + h.recordEmpty(readHandlerEventSearch) + } + next, err := encodeOptionalEventCursor(result.NextCursor) + if err != nil { + writeReadError(w, http.StatusInternalServerError, errorCodeUnavailable, "unavailable", "") + return + } + writeReadJSON(w, http.StatusOK, eventSearchResponse{Events: result.Events, NextCursor: next}) + }) +} + +func (h *ReadHandler) TraceByID(w http.ResponseWriter, r *http.Request) { + h.observe(readHandlerTraceGet, func() { + if !requireGET(w, r) { + return + } + if err := rejectUnknownParams(r.URL.Query(), nil); err != nil { + writeReadError(w, http.StatusBadRequest, err.code, err.message, err.detail) + return + } + traceID := singlePathTail(r.URL.Path, "/v1/traces/") + if traceID == "" { + h.recordNotFound(readHandlerTraceGet) + writeReadError(w, http.StatusNotFound, errorCodeNotFound, "trace not found", "") + return + } + result, ok := h.reader.GetTrace(traceID) + if !ok { + h.recordNotFound(readHandlerTraceGet) + writeReadError(w, http.StatusNotFound, errorCodeNotFound, "trace not found", "") + return + } + writeReadJSON(w, http.StatusOK, traceGetResponse(result)) + }) +} + +func (h *ReadHandler) RecentTraces(w http.ResponseWriter, r *http.Request) { + h.observe(readHandlerTracesRecent, func() { + if !requireGET(w, r) { + return + } + params, ok := h.parseSearchParams(w, r, 24*time.Hour, allowedRecentTracesParams, false) + if !ok { + return + } + result := h.reader.RecentTraces(params.Filter, params.TraceCursor, params.Limit) + if len(result.Traces) == 0 { + h.recordEmpty(readHandlerTracesRecent) + } + next, err := encodeOptionalTraceCursor(result.NextCursor) + if err != nil { + writeReadError(w, http.StatusInternalServerError, errorCodeUnavailable, "unavailable", "") + return + } + writeReadJSON(w, http.StatusOK, recentTracesResponse{Traces: result.Traces, NextCursor: next}) + }) +} + +func (h *ReadHandler) TraceStory(w http.ResponseWriter, r *http.Request) { + h.observe(readHandlerTraceStory, func() { + if !requireGET(w, r) { + return + } + q := r.URL.Query() + if err := rejectUnknownParams(q, allowedTraceStoryParams); err != nil { + writeReadError(w, http.StatusBadRequest, err.code, err.message, err.detail) + return + } + eventID, traceID := q.Get("event_id"), q.Get("trace_id") + if (eventID == "") == (traceID == "") { + writeReadError(w, http.StatusBadRequest, errorCodeBadRequest, "bad request", "exactly one of event_id or trace_id is required") + return + } + var story StoryResponse + var ok bool + if eventID != "" { + story, ok = h.reader.TraceStoryByEventID(eventID) + } else { + story, ok = h.reader.TraceStoryByTraceID(traceID) + } + if !ok { + h.recordNotFound(readHandlerTraceStory) + writeReadError(w, http.StatusNotFound, errorCodeNotFound, "trace story not found", "") + return + } + writeReadJSON(w, http.StatusOK, story) + }) +} + +func (h *ReadHandler) Errors(w http.ResponseWriter, r *http.Request) { + h.observe(readHandlerErrors, func() { + if !requireGET(w, r) { + return + } + params, ok := h.parseErrorsParams(w, r) + if !ok { + return + } + result := h.reader.Errors(params.Filter, params.ErrorCursor, params.Limit) + if len(result.Rows) == 0 { + h.recordEmpty(readHandlerErrors) + } + next, err := encodeOptionalErrorCursor(result.NextCursor) + if err != nil { + writeReadError(w, http.StatusInternalServerError, errorCodeUnavailable, "unavailable", "") + return + } + rows := result.Rows + if rows == nil { + rows = []ErrorRow{} + } + writeReadJSON(w, http.StatusOK, errorsResponse{Window: result.Window, Rows: rows, NextCursor: next}) + }) +} + +func (h *ReadHandler) BlastRadius(w http.ResponseWriter, r *http.Request) { + h.observe(readHandlerBlastRadius, func() { + if !requireGET(w, r) { + return + } + q := r.URL.Query() + if err := rejectUnknownParams(q, allowedBlastRadiusParams); err != nil { + writeReadError(w, http.StatusBadRequest, err.code, err.message, err.detail) + return + } + now := h.now() + since, until, qerr := parseTimeWindow(q, now, time.Hour, h.maxWindow) + if qerr != nil { + writeReadError(w, http.StatusBadRequest, qerr.code, qerr.message, qerr.detail) + return + } + key, ok := parseBlastKey(q) + if !ok { + writeReadError(w, http.StatusBadRequest, errorCodeBadRequest, "bad request", "invalid error family key") + return + } + result := h.reader.BlastRadius(SearchFilter{Since: since, Until: until}, key) + if result.AffectedRequests == 0 { + h.recordEmpty(readHandlerBlastRadius) + } + writeReadJSON(w, http.StatusOK, result) + }) +} + +type parsedReadParams struct { + Filter SearchFilter + EventCursor *EventCursor + TraceCursor *TraceCursor + ErrorCursor *ErrorCursor + Limit int +} + +func (h *ReadHandler) parseSearchParams(w http.ResponseWriter, r *http.Request, defaultWindow time.Duration, allowed map[string]struct{}, eventCursor bool) (parsedReadParams, bool) { + q := r.URL.Query() + if err := rejectUnknownParams(q, allowed); err != nil { + writeReadError(w, http.StatusBadRequest, err.code, err.message, err.detail) + return parsedReadParams{}, false + } + limit, qerr := parseLimit(q) + if qerr != nil { + writeReadError(w, http.StatusBadRequest, qerr.code, qerr.message, qerr.detail) + return parsedReadParams{}, false + } + includeSuppressed, qerr := parseIncludeSuppressed(q) + if qerr != nil { + writeReadError(w, http.StatusBadRequest, qerr.code, qerr.message, qerr.detail) + return parsedReadParams{}, false + } + statuses, qerr := parseStatusCSV(q) + if qerr != nil { + writeReadError(w, http.StatusBadRequest, qerr.code, qerr.message, qerr.detail) + return parsedReadParams{}, false + } + now := h.now() + since, until, qerr := parseTimeWindow(q, now, defaultWindow, h.maxWindow) + if qerr != nil { + writeReadError(w, http.StatusBadRequest, qerr.code, qerr.message, qerr.detail) + return parsedReadParams{}, false + } + params := parsedReadParams{ + Limit: limit, + Filter: SearchFilter{ + Service: q.Get("service"), + Statuses: statuses, + ErrorCode: q.Get("error_code"), + TraceID: q.Get("trace_id"), + Since: since, + Until: until, + IncludeSuppressed: includeSuppressed || statusIncludes(statuses, eventv2.StatusSuppressed), + }, + } + if raw := q.Get("cursor"); raw != "" { + if eventCursor { + cursor, err := DecodeEventCursor(raw) + if err != nil { + writeReadError(w, http.StatusBadRequest, errorCodeBadRequest, "bad request", "invalid cursor") + return parsedReadParams{}, false + } + params.EventCursor = &cursor + } else { + cursor, err := DecodeTraceCursor(raw) + if err != nil { + writeReadError(w, http.StatusBadRequest, errorCodeBadRequest, "bad request", "invalid cursor") + return parsedReadParams{}, false + } + params.TraceCursor = &cursor + } + } + return params, true +} + +func (h *ReadHandler) parseErrorsParams(w http.ResponseWriter, r *http.Request) (parsedReadParams, bool) { + q := r.URL.Query() + if err := rejectUnknownParams(q, allowedErrorsParams); err != nil { + writeReadError(w, http.StatusBadRequest, err.code, err.message, err.detail) + return parsedReadParams{}, false + } + limit, qerr := parseLimit(q) + if qerr != nil { + writeReadError(w, http.StatusBadRequest, qerr.code, qerr.message, qerr.detail) + return parsedReadParams{}, false + } + statuses, qerr := parseErrorStatuses(q) + if qerr != nil { + writeReadError(w, http.StatusBadRequest, qerr.code, qerr.message, qerr.detail) + return parsedReadParams{}, false + } + since, until, qerr := parseTimeWindow(q, h.now(), time.Hour, h.maxWindow) + if qerr != nil { + writeReadError(w, http.StatusBadRequest, qerr.code, qerr.message, qerr.detail) + return parsedReadParams{}, false + } + params := parsedReadParams{ + Limit: limit, + Filter: SearchFilter{ + Service: q.Get("service"), + Statuses: statuses, + Since: since, + Until: until, + }, + } + if raw := q.Get("cursor"); raw != "" { + cursor, err := DecodeErrorCursor(raw) + if err != nil { + writeReadError(w, http.StatusBadRequest, errorCodeBadRequest, "bad request", "invalid cursor") + return parsedReadParams{}, false + } + params.ErrorCursor = &cursor + } + return params, true +} + +type eventGetResponse struct { + Event *eventv2.Event `json:"event"` +} + +type eventSearchResponse struct { + Events []*eventv2.Event `json:"events"` + NextCursor *string `json:"next_cursor"` +} + +type traceGetResponse struct { + TraceID string `json:"trace_id"` + Events []*eventv2.Event `json:"events"` + Linkage string `json:"linkage"` +} + +type recentTracesResponse struct { + Traces []TraceSummary `json:"traces"` + NextCursor *string `json:"next_cursor"` +} + +type errorsResponse struct { + Window string `json:"window"` + Rows []ErrorRow `json:"rows"` + NextCursor *string `json:"next_cursor"` +} + +type readErrorBody struct { + Error readErrorDetail `json:"error"` +} + +type readErrorDetail struct { + Code string `json:"code"` + Message string `json:"message"` + Detail string `json:"detail,omitempty"` +} + +var allowedEventSearchParams = map[string]struct{}{ + "service": {}, "status": {}, "error_code": {}, "trace_id": {}, "since": {}, "until": {}, "window": {}, "include_suppressed": {}, "cursor": {}, "limit": {}, +} + +var allowedRecentTracesParams = map[string]struct{}{ + "service": {}, "status": {}, "since": {}, "until": {}, "window": {}, "include_suppressed": {}, "cursor": {}, "limit": {}, +} + +var allowedTraceStoryParams = map[string]struct{}{ + "event_id": {}, "trace_id": {}, +} + +var allowedErrorsParams = map[string]struct{}{ + "service": {}, "status": {}, "since": {}, "until": {}, "window": {}, "cursor": {}, "limit": {}, +} + +var allowedBlastRadiusParams = map[string]struct{}{ + "service": {}, "step": {}, "error_code": {}, "error_family": {}, "since": {}, "until": {}, "window": {}, +} + +func requireGET(w http.ResponseWriter, r *http.Request) bool { + if r.Method == http.MethodGet { + return true + } + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return false +} + +func singlePathTail(path, prefix string) string { + if !strings.HasPrefix(path, prefix) { + return "" + } + tail := strings.TrimPrefix(path, prefix) + if tail == "" || strings.Contains(tail, "/") { + return "" + } + return tail +} + +func writeReadJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeReadError(w http.ResponseWriter, status int, code, message, detail string) { + writeReadJSON(w, status, readErrorBody{Error: readErrorDetail{Code: code, Message: message, Detail: detail}}) +} + +func encodeOptionalEventCursor(cursor *EventCursor) (*string, error) { + if cursor == nil { + return nil, nil + } + encoded, err := EncodeEventCursor(*cursor) + if err != nil { + return nil, err + } + return &encoded, nil +} + +func encodeOptionalTraceCursor(cursor *TraceCursor) (*string, error) { + if cursor == nil { + return nil, nil + } + encoded, err := EncodeTraceCursor(*cursor) + if err != nil { + return nil, err + } + return &encoded, nil +} + +func encodeOptionalErrorCursor(cursor *ErrorCursor) (*string, error) { + if cursor == nil { + return nil, nil + } + encoded, err := EncodeErrorCursor(*cursor) + if err != nil { + return nil, err + } + return &encoded, nil +} + +func parseErrorStatuses(q url.Values) (map[eventv2.Status]struct{}, *queryError) { + statuses, err := parseStatusCSV(q) + if err != nil { + return nil, err + } + for status := range statuses { + if !isFailedStatus(status) { + return nil, &queryError{code: errorCodeBadRequest, message: "bad request", detail: "status must be one of error, timeout, partial, aborted"} + } + } + return statuses, nil +} + +func parseBlastKey(q url.Values) (BlastKeyMode, bool) { + service, step, code := q.Get("service"), q.Get("step"), q.Get("error_code") + if service != "" || step != "" { + if service == "" || step == "" || code == "" { + return BlastKeyMode{}, false + } + return BlastKeyMode{Key: BlastKey{Service: service, Step: step, ErrorCode: code}}, true + } + if display := q.Get("error_family"); display != "" { + key, ok := parseErrorFamilyDisplay(display) + if !ok { + return BlastKeyMode{}, false + } + return BlastKeyMode{Key: key}, true + } + if code == "" { + return BlastKeyMode{}, false + } + return BlastKeyMode{Key: BlastKey{ErrorCode: code}, CrossCode: true}, true +} + +func (h *ReadHandler) observe(handler string, fn func()) { + start := time.Now() + defer func() { + if h.metrics != nil { + h.metrics.V2ReadLatency.WithLabelValues(handler).Observe(time.Since(start).Seconds()) + } + }() + fn() +} + +func (h *ReadHandler) recordEmpty(handler string) { + if h.metrics != nil { + h.metrics.V2ReadEmpty.WithLabelValues(handler).Inc() + } +} + +func (h *ReadHandler) recordNotFound(handler string) { + if h.metrics != nil { + h.metrics.V2ReadNotFound.WithLabelValues(handler).Inc() + } +} diff --git a/internal/ingest/v2/readhandler_test.go b/internal/ingest/v2/readhandler_test.go new file mode 100644 index 0000000..366a979 --- /dev/null +++ b/internal/ingest/v2/readhandler_test.go @@ -0,0 +1,204 @@ +package ingestv2 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/sssmaran/WaylogCLI/internal/ingest" + "github.com/sssmaran/WaylogCLI/internal/metrics" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +func TestReadHandlerEventGetAndNotFound(t *testing.T) { + h := newTestReadHandler(t, nil) + h.reader.index.Insert(testTraceEvent("event-1", "trace", "svc", eventv2.StatusSuppressed, testTime(0))) + + rec := readGet(t, h.EventByID, "/v1/events/event-1") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var ok eventGetResponse + if err := json.Unmarshal(rec.Body.Bytes(), &ok); err != nil { + t.Fatal(err) + } + if ok.Event.EventID != "event-1" || ok.Event.Status != eventv2.StatusSuppressed { + t.Fatalf("event=%+v", ok.Event) + } + + rec = readGet(t, h.EventByID, "/v1/events/missing") + expectReadError(t, rec, http.StatusNotFound, errorCodeNotFound) +} + +func TestReadHandlerSearchRejectsBadParamsAndReturnsNullCursor(t *testing.T) { + h := newTestReadHandler(t, nil) + rec := readGet(t, h.EventSearch, "/v1/events/search?foo=bar") + expectReadError(t, rec, http.StatusBadRequest, errorCodeBadRequest) + + rec = readGet(t, h.EventSearch, "/v1/events/search?limit=201") + expectReadError(t, rec, http.StatusBadRequest, errorCodeOverLimit) + + rec = readGet(t, h.EventSearch, "/v1/events/search") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var body map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if string(body["events"]) != "[]" || string(body["next_cursor"]) != "null" { + t.Fatalf("body=%s", rec.Body.String()) + } +} + +func TestReadHandlerRejectsWindowOverHotWindowAndOldSince(t *testing.T) { + h := newTestReadHandler(t, nil) + rec := readGet(t, h.EventSearch, "/v1/events/search?window=25h") + expectReadError(t, rec, http.StatusBadRequest, errorCodeOverLimit) + + old := testTime(0).Add(-25 * time.Hour).Format(time.RFC3339Nano) + rec = readGet(t, h.EventSearch, "/v1/events/search?since="+old) + expectReadError(t, rec, http.StatusBadRequest, errorCodeOverLimit) +} + +func TestReadHandlerTraceGetAndRecent(t *testing.T) { + h := newTestReadHandler(t, nil) + root := testTraceEvent("root", "trace", "gateway", eventv2.StatusOK, testTime(0)) + root.Steps = []eventv2.Step{{Name: "call.payment", SpanID: "span-payment", Status: "ok"}} + payment := testTraceEvent("payment", "trace", "payment", eventv2.StatusError, testTime(1)) + payment.ParentSpanID = "span-payment" + payment.Anchor = &eventv2.Anchor{Step: "charge", ErrorCode: "PMT_502"} + h.reader.index.Insert(payment) + h.reader.index.Insert(root) + + rec := readGet(t, h.TraceByID, "/v1/traces/trace") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var trace traceGetResponse + if err := json.Unmarshal(rec.Body.Bytes(), &trace); err != nil { + t.Fatal(err) + } + if trace.Linkage != LinkageCausal || ids(trace.Events) != "root,payment" { + t.Fatalf("trace=%+v", trace) + } + + rec = readGet(t, h.RecentTraces, "/v1/traces/recent?service=payment") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var recent recentTracesResponse + if err := json.Unmarshal(rec.Body.Bytes(), &recent); err != nil { + t.Fatal(err) + } + if len(recent.Traces) != 1 || recent.Traces[0].Status != eventv2.StatusError { + t.Fatalf("recent=%+v", recent) + } +} + +func TestReadHandlerCORSPreflightAndPrefixRouteSafety(t *testing.T) { + h := newTestReadHandler(t, nil) + mux := http.NewServeMux() + wrap := func(fn http.HandlerFunc) http.Handler { + return http.HandlerFunc(ingest.CORSWrap("*", "GET, OPTIONS", fn)) + } + mux.Handle("/v1/events/search", wrap(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) })) + mux.Handle("/v1/events/", wrap(h.EventByID)) + mux.Handle("/v1/traces/recent", wrap(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) })) + mux.Handle("/v1/traces/", wrap(h.TraceByID)) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodOptions, "/v1/events/event-1", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("OPTIONS status=%d", rec.Code) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/v1/events/search", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusTeapot { + t.Fatalf("search route captured by prefix: status=%d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/v1/traces/recent", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusAccepted { + t.Fatalf("recent route captured by prefix: status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestReadHandlerMetrics(t *testing.T) { + reg := prometheus.NewRegistry() + m := metrics.New(reg) + h := newTestReadHandler(t, m) + readGet(t, h.EventByID, "/v1/events/missing") + readGet(t, h.EventSearch, "/v1/events/search") + + fm := gatherMap(t, reg) + if got := counterWithLabel(fm["waylog_v2_read_not_found_total"], "handler", readHandlerEventGet); got != 1 { + t.Fatalf("not_found=%v want 1", got) + } + if got := counterWithLabel(fm["waylog_v2_read_empty_total"], "handler", readHandlerEventSearch); got != 1 { + t.Fatalf("empty=%v want 1", got) + } + if got := histogramCountWithLabel(fm["waylog_v2_read_latency_seconds"], "handler", readHandlerEventGet); got != 2 { + t.Fatalf("event_get latency count=%v want 2", got) + } + if got := histogramCountWithLabel(fm["waylog_v2_read_latency_seconds"], "handler", readHandlerEventSearch); got != 2 { + t.Fatalf("event_search latency count=%v want 2", got) + } +} + +func newTestReadHandler(t *testing.T, m *metrics.Metrics) *ReadHandler { + t.Helper() + idx := NewRecentIndex(nil) + h := NewReadHandler(NewReader(idx), m, 24*time.Hour) + h.now = func() time.Time { return testTime(0).Add(24 * time.Hour) } + return h +} + +func readGet(t *testing.T, h http.HandlerFunc, target string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, target, nil) + h(rec, req) + return rec +} + +func expectReadError(t *testing.T, rec *httptest.ResponseRecorder, status int, code string) { + t.Helper() + if rec.Code != status { + t.Fatalf("status=%d want %d body=%s", rec.Code, status, rec.Body.String()) + } + if !strings.Contains(rec.Header().Get("Content-Type"), "application/json") { + t.Fatalf("content-type=%q", rec.Header().Get("Content-Type")) + } + var body readErrorBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode error: %v body=%s", err, rec.Body.String()) + } + if body.Error.Code != code { + t.Fatalf("code=%q want %q body=%s", body.Error.Code, code, rec.Body.String()) + } +} + +func histogramCountWithLabel(mf *dto.MetricFamily, label, value string) uint64 { + if mf == nil { + return 0 + } + for _, metric := range mf.GetMetric() { + for _, lp := range metric.GetLabel() { + if lp.GetName() == label && lp.GetValue() == value && metric.GetHistogram() != nil { + return metric.GetHistogram().GetSampleCount() + } + } + } + return 0 +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 68c8007..fd59cf2 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -32,6 +32,9 @@ type Metrics struct { V2ValidateLatency prometheus.Histogram V2WALWriteLatency prometheus.Histogram V2ProjectLatency prometheus.Histogram + V2ReadLatency *prometheus.HistogramVec + V2ReadEmpty *prometheus.CounterVec + V2ReadNotFound *prometheus.CounterVec ReplayLagSeconds prometheus.Gauge ReplayInProgress prometheus.Gauge @@ -176,6 +179,24 @@ func New(reg *prometheus.Registry) *Metrics { Help: "Schema-2.0 per-event recent-index projection latency.", Buckets: defaultBuckets, }) + m.V2ReadLatency = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "waylog_v2_read_latency_seconds", + Help: "Schema-2.0 read endpoint latency by handler.", + Buckets: defaultBuckets, + }, []string{"handler"}) + m.V2ReadEmpty = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "waylog_v2_read_empty_total", + Help: "Schema-2.0 read endpoint 200 responses with empty result arrays by handler.", + }, []string{"handler"}) + m.V2ReadNotFound = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "waylog_v2_read_not_found_total", + Help: "Schema-2.0 read endpoint 404 responses by handler.", + }, []string{"handler"}) + for _, handler := range []string{"event_get", "event_search", "trace_get", "traces_recent", "trace_story", "errors", "blast_radius"} { + m.V2ReadLatency.WithLabelValues(handler).Observe(0) + m.V2ReadEmpty.WithLabelValues(handler).Add(0) + m.V2ReadNotFound.WithLabelValues(handler).Add(0) + } m.ReplayLagSeconds = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "waylog_replay_lag_seconds", @@ -360,6 +381,7 @@ func New(reg *prometheus.Registry) *Metrics { m.V2ReplaySkipped, m.V2TypedDecodeFailed, m.V2ProjectPanic, m.V2ValidateLatency, m.V2WALWriteLatency, m.V2ProjectLatency, + m.V2ReadLatency, m.V2ReadEmpty, m.V2ReadNotFound, m.ReplayLagSeconds, m.ReplayInProgress, m.ReplayFailuresTotal, m.Ready, m.InFlightRequests, m.SnapshotLastSuccess, m.SnapshotLastError, From 65a27937e2379db333ec7ee8ff56049ee3cebd9a Mon Sep 17 00:00:00 2001 From: skota-hash Date: Wed, 29 Apr 2026 01:03:51 -0400 Subject: [PATCH 05/12] feat(cli): added v2 operator CLI over read APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced cmd/waylog's snapshot/LLM-driven CLI with the Phase 2 schema-v2 operator CLI. The new CLI talks to the running ingest server's v2 read APIs and implements the §6.2 operator verbs: - waylog errors - waylog trace - waylog explain - waylog blast - waylog search Add internal/cli/v2 with command dispatch, an HTTP read client, capability preflight, typed API/transport errors, deterministic exit codes, text renderers, JSON output, and unit tests. The CLI reads INGEST_ADDR, WAYLOG_READ_KEY, and WAYLOG_CLI_TIMEOUT, supports global --addr, --api-key, --timeout, and --json, and normalizes bare host:port addresses. Add /v1/capabilities.v2_reads.enabled, wired from WAYLOG_V2_READS, so the CLI can fail early with a clear message when the server has not enabled v2 reads. Keep WAYLOG_V2_READS default false. Add pkg/api/v2 as the shared schema-v2 read API contract package. Move shared response DTOs, linkage/view-mode constants, and error-family display parse/format helpers there so ingest and CLI compile against the same JSON shapes instead of duplicating structs. Land the Slice 6 cleanup prologue: - add eventv2.Status.IsFailed() - add StepStatus and LogLevel constants - replace local failed-status predicates in ingest/v2 - centralize error-family round-trip behavior - restore include_suppressed=true behavior for suppressed-only recent traces Update README, CHANGELOG, and docs/env.md to document the v2 CLI and required server capability. Verification: - go vet ./... - go build ./... - go test ./... - go test -race ./internal/cli/v2 ./internal/ingest/v2 - make build - make test - git diff --check --- README.md | 17 +- cmd/ingest/main.go | 1 + cmd/waylog/main.go | 33 +-- docs/env.md | 12 +- internal/cli/v2/client.go | 238 ++++++++++++++++++++ internal/cli/v2/client_test.go | 73 ++++++ internal/cli/v2/cmd.go | 297 +++++++++++++++++++++++++ internal/cli/v2/cmd_test.go | 84 +++++++ internal/cli/v2/render.go | 177 +++++++++++++++ internal/cli/v2/render_test.go | 56 +++++ internal/cli/v2/types.go | 62 ++++++ internal/ingest/handler.go | 8 +- internal/ingest/handler_test.go | 26 +++ internal/ingest/v2/anchor.go | 15 +- internal/ingest/v2/derived.go | 125 ++--------- internal/ingest/v2/derived_test.go | 29 +-- internal/ingest/v2/errorfamily.go | 22 ++ internal/ingest/v2/errorfamily_test.go | 29 +++ internal/ingest/v2/index.go | 11 +- internal/ingest/v2/linkage.go | 5 +- internal/ingest/v2/reader.go | 24 +- internal/ingest/v2/reader_test.go | 4 +- internal/ingest/v2/readhandler.go | 30 +-- internal/ingest/v2/readhandler_test.go | 2 +- pkg/api/v2/types.go | 161 ++++++++++++++ pkg/api/v2/types_test.go | 29 +++ pkg/event/v2/event.go | 19 ++ pkg/event/v2/event_test.go | 14 ++ 28 files changed, 1375 insertions(+), 228 deletions(-) create mode 100644 internal/cli/v2/client.go create mode 100644 internal/cli/v2/client_test.go create mode 100644 internal/cli/v2/cmd.go create mode 100644 internal/cli/v2/cmd_test.go create mode 100644 internal/cli/v2/render.go create mode 100644 internal/cli/v2/render_test.go create mode 100644 internal/cli/v2/types.go create mode 100644 internal/ingest/v2/errorfamily.go create mode 100644 internal/ingest/v2/errorfamily_test.go create mode 100644 pkg/api/v2/types.go create mode 100644 pkg/api/v2/types_test.go diff --git a/README.md b/README.md index 9d67739..8a84e25 100644 --- a/README.md +++ b/README.md @@ -136,13 +136,17 @@ Runs only the ingest server. Point your own services at it via an SDK or OTLP. F ### CLI ```bash -waylog "show top errors" -waylog "explain request 7f3a2b..." -waylog "trace summary for 7f3a2b..." -waylog "graph_query expr='error_code=PMT_502' window='10m'" -waylog "compare_windows current='10m' baseline='10m' offset='1h'" +WAYLOG_V2_READS=true ./ingest + +waylog errors --window 15m +waylog explain trace_01HX... +waylog trace trace_01HX... +waylog blast --service checkout --step payment.charge --code PMT_502 +waylog search PMT_502 --window 1h ``` +The `waylog` binary is now the v2 operator CLI over the running ingest server's read APIs. It requires the server to advertise `v2_reads.enabled=true` from `/v1/capabilities` and uses `INGEST_ADDR`, `WAYLOG_READ_KEY`, and `WAYLOG_CLI_TIMEOUT` by default. Add `--json` to any verb for machine-readable output. + ### REST (direct tool call) ```bash @@ -282,7 +286,8 @@ Public alpha. APIs may break before 1.0. - agent-native REST (`/v1/tools/*`, `/v1/ask`, `/v1/plans/execute`) with idempotency and structured envelopes - `/v1/traces/story?format=tree` and tree rendering in the dashboard - dashboard: Geist theme + light-mode toggle, failing-traces banner, bento layout, deploy-diff, SSE live -- live TUI (`waylog-live --dev` streams via SSE), MCP stdio, CLI with LLM tool routing +- v2 operator CLI (`errors`, `trace`, `explain`, `blast`, `search`) over read APIs +- live TUI (`waylog-live --dev` streams via SSE), MCP stdio - scoped auth (write/read/agent) with startup validation **Planned:** diff --git a/cmd/ingest/main.go b/cmd/ingest/main.go index e26a724..83f8d1f 100644 --- a/cmd/ingest/main.go +++ b/cmd/ingest/main.go @@ -234,6 +234,7 @@ func main() { PlanStore: planStore, GraphHotWindow: graphHotWindow, OTLPEnabled: otlpEnabled, + V2ReadsEnabled: v2ReadsEnabled, }) // SSE hub for real-time dashboard updates diff --git a/cmd/waylog/main.go b/cmd/waylog/main.go index 1775844..b9faf8c 100644 --- a/cmd/waylog/main.go +++ b/cmd/waylog/main.go @@ -1,40 +1,11 @@ package main import ( - "errors" - "log/slog" "os" - "time" - "github.com/sssmaran/WaylogCLI/internal/cli" - "github.com/sssmaran/WaylogCLI/internal/config" - graphstore "github.com/sssmaran/WaylogCLI/internal/graph/store" - "github.com/sssmaran/WaylogCLI/internal/persist" + cliv2 "github.com/sssmaran/WaylogCLI/internal/cli/v2" ) func main() { - config.LoadDotEnv(".env") - - snapshotPath := config.Getenv("SNAPSHOT_PATH", "./data/graph_snapshot.json") - store := graphstore.NewStore() - - if snap, source, err := persist.LoadWithSource(snapshotPath); err == nil { - store.Restore(snap.Graph) - slog.Info("snapshot loaded", - "path", snapshotPath, - "nodes", snap.NodeCount, - "edges", snap.EdgeCount, - "saved_at", snap.SavedAt.Format(time.RFC3339), - ) - if source == "backup" { - slog.Info("snapshot loaded from backup", "path", snapshotPath+".bak") - } - } else if errors.Is(err, persist.ErrSnapshotMissing) { - slog.Info("no snapshot found, starting fresh") - } else { - slog.Warn("no snapshot loaded", "err", err) - } - - cli.SetDefaultStore(store) - cli.Run(os.Args[1:]) + os.Exit(cliv2.RunCLI(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } diff --git a/docs/env.md b/docs/env.md index 745988c..1a90ce0 100644 --- a/docs/env.md +++ b/docs/env.md @@ -6,7 +6,7 @@ Reference for configuring the Waylog ingest server and SDK. All variables are re | Variable | Purpose | |---|---| -| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Required for CLI LLM-backed Ask flows | +| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Required for legacy server-side Ask flows | ## Auth @@ -35,6 +35,16 @@ Scoped keys. See the Auth section of the [README](../README.md). | `IDLE_TIMEOUT` | `120s` | HTTP idle timeout | | `CORS_ORIGIN` | `*` | Allowed CORS origin for read APIs | +## CLI + +The `waylog` CLI calls the running ingest server's v2 read APIs. The server must run with `WAYLOG_V2_READS=true`. + +| Variable | Default | Purpose | +|---|---|---| +| `INGEST_ADDR` | `http://localhost:8080` | CLI target ingest base URL. Bare `host:port` values are normalized to `http://host:port` | +| `WAYLOG_READ_KEY` | — | Read-scope API key sent as `Authorization: Bearer ` | +| `WAYLOG_CLI_TIMEOUT` | `5s` | Per-request CLI HTTP timeout | + ## Storage and persistence | Variable | Default | Purpose | diff --git a/internal/cli/v2/client.go b/internal/cli/v2/client.go new file mode 100644 index 0000000..6c7dab9 --- /dev/null +++ b/internal/cli/v2/client.go @@ -0,0 +1,238 @@ +package cliv2 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +const defaultBaseURL = "http://localhost:8080" + +type Client struct { + base string + apiKey string + http *http.Client +} + +type APIError struct { + Status int + Code string + Message string + Detail string +} + +func (e *APIError) Error() string { + if e.Detail != "" { + return fmt.Sprintf("%s: %s", e.Code, e.Detail) + } + if e.Message != "" { + return fmt.Sprintf("%s: %s", e.Code, e.Message) + } + return e.Code +} + +type TransportError struct { + Err error +} + +func (e *TransportError) Error() string { return e.Err.Error() } +func (e *TransportError) Unwrap() error { return e.Err } + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = os.Getenv("INGEST_ADDR") + } + if cfg.BaseURL == "" { + cfg.BaseURL = defaultBaseURL + } + if cfg.APIKey == "" { + cfg.APIKey = os.Getenv("WAYLOG_READ_KEY") + } + if cfg.Timeout <= 0 { + cfg.Timeout = parseEnvDuration("WAYLOG_CLI_TIMEOUT", 5*time.Second) + } + return &Client{ + base: NormalizeBaseURL(cfg.BaseURL), + apiKey: cfg.APIKey, + http: &http.Client{Timeout: cfg.Timeout}, + } +} + +func NormalizeBaseURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return defaultBaseURL + } + if strings.HasPrefix(raw, ":") { + raw = "localhost" + raw + } + if !strings.Contains(raw, "://") { + raw = "http://" + raw + } + return strings.TrimRight(raw, "/") +} + +func (c *Client) Capabilities(ctx context.Context) (CapabilitiesResponse, error) { + var out CapabilitiesResponse + err := c.do(ctx, "/v1/capabilities", nil, &out) + return out, err +} + +func (c *Client) Errors(ctx context.Context, p ErrorsParams) (ErrorsResponse, error) { + q := url.Values{} + addQuery(q, "window", p.Window) + addQuery(q, "service", p.Service) + addQuery(q, "cursor", p.Cursor) + addLimit(q, p.Limit) + var out ErrorsResponse + err := c.do(ctx, "/v1/errors", q, &out) + return out, err +} + +func (c *Client) Trace(ctx context.Context, traceID string) (TraceGetResponse, error) { + var out TraceGetResponse + err := c.do(ctx, "/v1/traces/"+url.PathEscape(traceID), nil, &out) + return out, err +} + +func (c *Client) Story(ctx context.Context, q StoryQuery) (StoryResponse, error) { + values := url.Values{} + addQuery(values, "event_id", q.EventID) + addQuery(values, "trace_id", q.TraceID) + var out StoryResponse + err := c.do(ctx, "/v1/traces/story", values, &out) + return out, err +} + +func (c *Client) Blast(ctx context.Context, p BlastParams) (BlastRadiusResponse, error) { + q := url.Values{} + addQuery(q, "service", p.Service) + addQuery(q, "step", p.Step) + addQuery(q, "error_code", p.ErrorCode) + addQuery(q, "error_family", p.ErrorFamily) + addQuery(q, "window", p.Window) + var out BlastRadiusResponse + err := c.do(ctx, "/v1/blast_radius", q, &out) + return out, err +} + +func (c *Client) Search(ctx context.Context, p SearchParams) (EventSearchResponse, error) { + q := url.Values{} + addQuery(q, "error_code", p.ErrorCode) + addQuery(q, "trace_id", p.TraceID) + addQuery(q, "service", p.Service) + addQuery(q, "status", p.Status) + addQuery(q, "window", p.Window) + addQuery(q, "cursor", p.Cursor) + addLimit(q, p.Limit) + var out EventSearchResponse + err := c.do(ctx, "/v1/events/search", q, &out) + return out, err +} + +func (c *Client) do(ctx context.Context, path string, q url.Values, out any) error { + u, err := url.Parse(c.base + path) + if err != nil { + return &TransportError{Err: err} + } + if len(q) > 0 { + u.RawQuery = q.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return &TransportError{Err: err} + } + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + resp, err := c.http.Do(req) + if err != nil { + return &TransportError{Err: err} + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return &TransportError{Err: err} + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return decodeAPIError(resp.StatusCode, body) + } + if out == nil || len(strings.TrimSpace(string(body))) == 0 { + return nil + } + if err := json.Unmarshal(body, out); err != nil { + return &TransportError{Err: fmt.Errorf("decode response: %w", err)} + } + return nil +} + +func decodeAPIError(status int, body []byte) error { + var env struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Detail string `json:"detail"` + } `json:"error"` + } + if err := json.Unmarshal(body, &env); err == nil && env.Error.Code != "" { + return &APIError{Status: status, Code: env.Error.Code, Message: env.Error.Message, Detail: env.Error.Detail} + } + code := "unavailable" + if status == http.StatusUnauthorized { + code = "unauthorized" + } else if status >= 400 && status < 500 { + code = "bad_request" + } + msg := strings.TrimSpace(string(body)) + if msg == "" { + msg = http.StatusText(status) + } + return &APIError{Status: status, Code: code, Message: msg} +} + +func exitCodeForError(err error) int { + var transport *TransportError + if errors.As(err, &transport) { + return 2 + } + var api *APIError + if errors.As(err, &api) { + if api.Status >= 500 { + return 4 + } + return 3 + } + return 2 +} + +func addQuery(q url.Values, key, value string) { + if value != "" { + q.Set(key, value) + } +} + +func addLimit(q url.Values, limit int) { + if limit > 0 { + q.Set("limit", strconv.Itoa(limit)) + } +} + +func parseEnvDuration(name string, fallback time.Duration) time.Duration { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback + } + d, err := time.ParseDuration(raw) + if err != nil || d <= 0 { + return fallback + } + return d +} diff --git a/internal/cli/v2/client_test.go b/internal/cli/v2/client_test.go new file mode 100644 index 0000000..8d7d621 --- /dev/null +++ b/internal/cli/v2/client_test.go @@ -0,0 +1,73 @@ +package cliv2 + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestNormalizeBaseURL(t *testing.T) { + tests := map[string]string{ + "": defaultBaseURL, + ":8080": "http://localhost:8080", + "localhost:8080": "http://localhost:8080", + "http://x/": "http://x", + } + for in, want := range tests { + if got := NormalizeBaseURL(in); got != want { + t.Fatalf("NormalizeBaseURL(%q)=%q want %q", in, got, want) + } + } +} + +func TestClientSerializesAuthAndQuery(t *testing.T) { + var gotPath, gotQuery, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + gotAuth = r.Header.Get("Authorization") + _ = json.NewEncoder(w).Encode(EventSearchResponse{}) + })) + defer srv.Close() + + client := NewClient(ClientConfig{BaseURL: srv.URL, APIKey: "read", Timeout: time.Second}) + if _, err := client.Search(context.Background(), SearchParams{ErrorCode: "PMT_502", Service: "checkout", Status: "error", Window: "15m", Limit: 10, Cursor: "abc"}); err != nil { + t.Fatal(err) + } + if gotPath != "/v1/events/search" || gotAuth != "Bearer read" { + t.Fatalf("path=%q auth=%q", gotPath, gotAuth) + } + for _, want := range []string{"error_code=PMT_502", "service=checkout", "status=error", "window=15m", "limit=10", "cursor=abc"} { + if !containsQuery(gotQuery, want) { + t.Fatalf("query=%q missing %q", gotQuery, want) + } + } +} + +func TestClientDecodesAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"missing","detail":"gone"}}`)) + })) + defer srv.Close() + + client := NewClient(ClientConfig{BaseURL: srv.URL, Timeout: time.Second}) + _, err := client.Trace(context.Background(), "missing") + api, ok := err.(*APIError) + if !ok || api.Code != "not_found" || api.Detail != "gone" || exitCodeForError(err) != 3 { + t.Fatalf("err=%#v", err) + } +} + +func containsQuery(raw, want string) bool { + for _, part := range strings.Split(raw, "&") { + if part == want { + return true + } + } + return false +} diff --git a/internal/cli/v2/cmd.go b/internal/cli/v2/cmd.go new file mode 100644 index 0000000..661b362 --- /dev/null +++ b/internal/cli/v2/cmd.go @@ -0,0 +1,297 @@ +package cliv2 + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + "time" +) + +const version = "v2-phase-2" + +type cliConfig struct { + addr string + apiKey string + timeout time.Duration + json bool +} + +func RunCLI(args []string, _ io.Reader, stdout, stderr io.Writer) int { + cfg, rest, control, err := parseGlobal(args) + if err != nil { + fmt.Fprintln(stderr, err) + printUsage(stderr) + return 1 + } + switch control { + case "help": + printUsage(stdout) + return 0 + case "version": + fmt.Fprintln(stdout, version) + return 0 + } + if len(rest) == 0 { + printUsage(stderr) + return 1 + } + + client := NewClient(ClientConfig{BaseURL: cfg.addr, APIKey: cfg.apiKey, Timeout: cfg.timeout}) + ctx := context.Background() + + switch rest[0] { + case "errors": + return runErrors(ctx, client, cfg, rest[1:], stdout, stderr) + case "trace": + return runTrace(ctx, client, cfg, rest[1:], stdout, stderr) + case "explain": + return runExplain(ctx, client, cfg, rest[1:], stdout, stderr) + case "blast": + return runBlast(ctx, client, cfg, rest[1:], stdout, stderr) + case "search": + return runSearch(ctx, client, cfg, rest[1:], stdout, stderr) + default: + fmt.Fprintf(stderr, "unknown command: %s\n", rest[0]) + printUsage(stderr) + return 1 + } +} + +func parseGlobal(args []string) (cliConfig, []string, string, error) { + cfg := cliConfig{ + addr: os.Getenv("INGEST_ADDR"), + apiKey: os.Getenv("WAYLOG_READ_KEY"), + timeout: parseEnvDuration("WAYLOG_CLI_TIMEOUT", 5*time.Second), + } + rest := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "-h" || arg == "--help": + return cfg, nil, "help", nil + case arg == "--version": + return cfg, nil, "version", nil + case arg == "--json": + cfg.json = true + case arg == "--addr" || arg == "--api-key" || arg == "--timeout": + if i+1 >= len(args) { + return cfg, nil, "", fmt.Errorf("%s requires a value", arg) + } + i++ + if err := setGlobalValue(&cfg, arg, args[i]); err != nil { + return cfg, nil, "", err + } + case strings.HasPrefix(arg, "--addr="): + if err := setGlobalValue(&cfg, "--addr", strings.TrimPrefix(arg, "--addr=")); err != nil { + return cfg, nil, "", err + } + case strings.HasPrefix(arg, "--api-key="): + if err := setGlobalValue(&cfg, "--api-key", strings.TrimPrefix(arg, "--api-key=")); err != nil { + return cfg, nil, "", err + } + case strings.HasPrefix(arg, "--timeout="): + if err := setGlobalValue(&cfg, "--timeout", strings.TrimPrefix(arg, "--timeout=")); err != nil { + return cfg, nil, "", err + } + default: + rest = append(rest, arg) + } + } + return cfg, rest, "", nil +} + +func setGlobalValue(cfg *cliConfig, key, value string) error { + switch key { + case "--addr": + cfg.addr = value + case "--api-key": + cfg.apiKey = value + case "--timeout": + d, err := time.ParseDuration(value) + if err != nil || d <= 0 { + return fmt.Errorf("invalid timeout: %s", value) + } + cfg.timeout = d + } + return nil +} + +func requireV2Reads(ctx context.Context, client *Client, stderr io.Writer) int { + caps, err := client.Capabilities(ctx) + if err != nil { + fmt.Fprintf(stderr, "capability check failed: %v\n", err) + return exitCodeForError(err) + } + if !caps.V2Reads.Enabled { + fmt.Fprintln(stderr, "server must run with WAYLOG_V2_READS=true for the v2 CLI") + return 3 + } + return 0 +} + +func runErrors(ctx context.Context, client *Client, cfg cliConfig, args []string, stdout, stderr io.Writer) int { + fs := newFlagSet("errors", stderr) + window := fs.String("window", "", "") + service := fs.String("service", "", "") + limit := fs.Int("limit", 0, "") + cursor := fs.String("cursor", "", "") + if err := fs.Parse(args); err != nil || fs.NArg() != 0 { + return usage(stderr, "usage: waylog errors [--window ] [--service ] [--limit ] [--cursor ] [--json]") + } + if gate := requireV2Reads(ctx, client, stderr); gate != 0 { + return gate + } + resp, err := client.Errors(ctx, ErrorsParams{Window: *window, Service: *service, Limit: *limit, Cursor: *cursor}) + return renderOrError(stdout, stderr, cfg.json, resp, err, RenderErrors) +} + +func runTrace(ctx context.Context, client *Client, cfg cliConfig, args []string, stdout, stderr io.Writer) int { + if len(args) != 1 { + return usage(stderr, "usage: waylog trace [--json]") + } + if gate := requireV2Reads(ctx, client, stderr); gate != 0 { + return gate + } + resp, err := client.Trace(ctx, args[0]) + return renderOrError(stdout, stderr, cfg.json, resp, err, RenderTrace) +} + +func runExplain(ctx context.Context, client *Client, cfg cliConfig, args []string, stdout, stderr io.Writer) int { + if len(args) != 1 { + return usage(stderr, "usage: waylog explain [--json]") + } + if gate := requireV2Reads(ctx, client, stderr); gate != 0 { + return gate + } + id := args[0] + resp, err := client.Story(ctx, StoryQuery{EventID: id}) + if isNotFound(err) { + resp, err = client.Story(ctx, StoryQuery{TraceID: id}) + } + return renderOrError(stdout, stderr, cfg.json, resp, err, RenderStory) +} + +func runBlast(ctx context.Context, client *Client, cfg cliConfig, args []string, stdout, stderr io.Writer) int { + fs := newFlagSet("blast", stderr) + service := fs.String("service", "", "") + step := fs.String("step", "", "") + errorCode := fs.String("code", "", "") + window := fs.String("window", "", "") + if err := fs.Parse(args); err != nil { + return usage(stderr, "usage: waylog blast (--service --step --code | --code | ) [--window ] [--json]") + } + p, err := resolveBlastForm(BlastParams{Service: *service, Step: *step, ErrorCode: *errorCode, Window: *window}, fs.Args()) + if err != nil { + return usage(stderr, err.Error()) + } + if gate := requireV2Reads(ctx, client, stderr); gate != 0 { + return gate + } + resp, err := client.Blast(ctx, p) + return renderOrError(stdout, stderr, cfg.json, resp, err, RenderBlast) +} + +func resolveBlastForm(flags BlastParams, positional []string) (BlastParams, error) { + if len(positional) > 1 { + return BlastParams{}, errors.New("usage: waylog blast (--service --step --code | --code | ) [--window ] [--json]") + } + if len(positional) == 1 { + if flags.Service != "" || flags.Step != "" || flags.ErrorCode != "" { + return BlastParams{}, errors.New("display error family cannot be combined with --service, --step, or --code") + } + flags.ErrorFamily = positional[0] + return flags, nil + } + if flags.Service != "" || flags.Step != "" { + if flags.Service == "" || flags.Step == "" || flags.ErrorCode == "" { + return BlastParams{}, errors.New("--service, --step, and --code must be supplied together") + } + return flags, nil + } + if flags.ErrorCode != "" { + return flags, nil + } + return BlastParams{}, errors.New("blast requires --code or a display error family") +} + +func runSearch(ctx context.Context, client *Client, cfg cliConfig, args []string, stdout, stderr io.Writer) int { + fs := newFlagSet("search", stderr) + service := fs.String("service", "", "") + status := fs.String("status", "", "") + window := fs.String("window", "", "") + limit := fs.Int("limit", 0, "") + cursor := fs.String("cursor", "", "") + errorCode := fs.String("error-code", "", "") + traceID := fs.String("trace-id", "", "") + if err := fs.Parse(args); err != nil || fs.NArg() > 1 { + return usage(stderr, "usage: waylog search [--service ] [--status ] [--window ] [--limit ] [--cursor ] [--json]") + } + query := "" + if fs.NArg() == 1 { + query = fs.Arg(0) + } + p := SearchParams{Service: *service, Status: *status, Window: *window, Limit: *limit, Cursor: *cursor, ErrorCode: *errorCode, TraceID: *traceID} + if p.ErrorCode == "" && p.TraceID == "" { + if query == "" { + return usage(stderr, "search requires , --error-code, or --trace-id") + } + p.ErrorCode = query + } + if gate := requireV2Reads(ctx, client, stderr); gate != 0 { + return gate + } + resp, err := client.Search(ctx, p) + return renderOrError(stdout, stderr, cfg.json, resp, err, RenderSearch) +} + +func newFlagSet(name string, stderr io.Writer) *flag.FlagSet { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(stderr) + return fs +} + +func renderOrError[T any](stdout, stderr io.Writer, asJSON bool, resp T, err error, render func(io.Writer, T)) int { + if err != nil { + fmt.Fprintln(stderr, err) + return exitCodeForError(err) + } + if asJSON { + if err := renderJSON(stdout, resp); err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + return 0 + } + render(stdout, resp) + return 0 +} + +func isNotFound(err error) bool { + var api *APIError + return errors.As(err, &api) && api.Code == "not_found" +} + +func usage(stderr io.Writer, msg string) int { + fmt.Fprintln(stderr, msg) + return 1 +} + +func printUsage(w io.Writer) { + fmt.Fprintln(w, `Usage: + waylog errors [--window ] [--service ] [--limit ] [--cursor ] [--json] + waylog trace [--json] + waylog explain [--json] + waylog blast (--service --step --code | --code | ) [--window ] [--json] + waylog search [--service ] [--status ] [--window ] [--limit ] [--cursor ] [--json] + +Global flags: + --addr ingest base URL (default INGEST_ADDR or http://localhost:8080) + --api-key read API key (default WAYLOG_READ_KEY) + --timeout HTTP timeout (default WAYLOG_CLI_TIMEOUT or 5s) + --json pretty-print raw JSON response + --version print version`) +} diff --git a/internal/cli/v2/cmd_test.go b/internal/cli/v2/cmd_test.go new file mode 100644 index 0000000..33589b9 --- /dev/null +++ b/internal/cli/v2/cmd_test.go @@ -0,0 +1,84 @@ +package cliv2 + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestRunCLIRequiresV2Reads(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(CapabilitiesResponse{}) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := RunCLI([]string{"--addr", srv.URL, "errors"}, nil, &stdout, &stderr) + if code != 3 || !strings.Contains(stderr.String(), "WAYLOG_V2_READS=true") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} + +func TestRunCLIErrorsHappyPath(t *testing.T) { + var gotPath, gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/capabilities" { + _, _ = w.Write([]byte(`{"v2_reads":{"enabled":true}}`)) + return + } + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + _, _ = w.Write([]byte(`{"window":"15m0s","rows":[],"next_cursor":null}`)) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := RunCLI([]string{"--addr", srv.URL, "errors", "--window", "15m", "--service", "checkout"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if gotPath != "/v1/errors" || !strings.Contains(gotQuery, "window=15m") || !strings.Contains(gotQuery, "service=checkout") { + t.Fatalf("path=%q query=%q", gotPath, gotQuery) + } + if !strings.Contains(stdout.String(), "No error families found.") { + t.Fatalf("stdout=%q", stdout.String()) + } +} + +func TestRunCLIExplainFallsBackToTraceID(t *testing.T) { + calls := []string{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/capabilities" { + _, _ = w.Write([]byte(`{"v2_reads":{"enabled":true}}`)) + return + } + calls = append(calls, r.URL.RawQuery) + if r.URL.Query().Get("event_id") != "" { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"missing"}}`)) + return + } + _, _ = w.Write([]byte(`{"trace_id":"trace","service":"checkout","status":"ok","anchor":null,"path":[],"logs":[],"downstream":[],"linkage":"timestamp_fallback"}`)) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := RunCLI([]string{"--addr", srv.URL, "explain", "trace"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if len(calls) != 2 || !strings.Contains(calls[0], "event_id=trace") || !strings.Contains(calls[1], "trace_id=trace") { + t.Fatalf("calls=%v", calls) + } +} + +func TestRunCLIUsage(t *testing.T) { + var stdout, stderr bytes.Buffer + code := RunCLI([]string{"search"}, nil, &stdout, &stderr) + if code != 1 { + t.Fatalf("code=%d stderr=%q", code, stderr.String()) + } +} diff --git a/internal/cli/v2/render.go b/internal/cli/v2/render.go new file mode 100644 index 0000000..5b831dc --- /dev/null +++ b/internal/cli/v2/render.go @@ -0,0 +1,177 @@ +package cliv2 + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "text/tabwriter" + "time" + + apiv2 "github.com/sssmaran/WaylogCLI/pkg/api/v2" +) + +func renderJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +func RenderErrors(w io.Writer, resp ErrorsResponse) { + if len(resp.Rows) == 0 { + fmt.Fprintln(w, "No error families found.") + renderNextCursor(w, resp.NextCursor) + return + } + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "ERROR FAMILY\tCOUNT\tAFFECTED TRACES\tAFFECTED USERS\tSAMPLE") + for _, row := range resp.Rows { + users := "null" + if row.AffectedUsers != nil { + users = fmt.Sprintf("%d", *row.AffectedUsers) + } + fmt.Fprintf(tw, "%s\t%d\t%d\t%s\t%s\n", + apiv2.FormatErrorFamily(row.ErrorFamily), + row.Count, + row.AffectedTraces, + users, + truncateList(row.SampleTraces), + ) + } + _ = tw.Flush() + renderNextCursor(w, resp.NextCursor) +} + +func RenderTrace(w io.Writer, resp TraceGetResponse) { + fmt.Fprintf(w, "trace_id: %s\n", resp.TraceID) + fmt.Fprintf(w, "linkage: %s\n\n", resp.Linkage) + if len(resp.Events) == 0 { + fmt.Fprintln(w, "No events found.") + return + } + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "EVENT\tSTATUS\tSERVICE\tSTART") + for _, ev := range resp.Events { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", truncateID(ev.EventID), ev.Status, ev.Service, formatTime(ev.TsStart)) + } + _ = tw.Flush() +} + +func RenderStory(w io.Writer, story StoryResponse) { + fmt.Fprintf(w, "trace_id: %s\n", story.TraceID) + fmt.Fprintf(w, "service: %s\n", story.Service) + if story.Route != "" { + fmt.Fprintf(w, "route: %s\n", story.Route) + } + fmt.Fprintf(w, "status: %s\n", story.Status) + fmt.Fprintf(w, "linkage: %s\n\n", story.Linkage) + + fmt.Fprintln(w, "first observable failing step:") + if story.Anchor == nil { + fmt.Fprintln(w, " none observed") + } else { + fmt.Fprintf(w, " %s -> %s\n", story.Anchor.Step, story.Anchor.ErrorCode) + } + + fmt.Fprintln(w, "\ncontributing path:") + if len(story.Path) == 0 { + fmt.Fprintln(w, " none") + } else { + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + for _, step := range story.Path { + detail := step.ErrorMsg + if detail == "" { + detail = step.ErrorCode + } + fmt.Fprintf(tw, " %s\t%s\t%d ms\t%s\n", step.Name, step.Status, step.DurationMS, detail) + } + _ = tw.Flush() + } + + if len(story.Logs) > 0 { + fmt.Fprintln(w, "\nlogs:") + for _, log := range story.Logs { + fmt.Fprintf(w, " +%d ms [%s] %s\n", log.TsOffsetMS, log.Level, log.Msg) + } + } + + fmt.Fprintln(w, "\ndownstream:") + if len(story.Downstream) == 0 { + fmt.Fprintln(w, " none") + return + } + for _, downstream := range story.Downstream { + target := downstream.Service + if downstream.Endpoint != "" { + target += " " + downstream.Endpoint + } + fmt.Fprintf(w, " %s -> %s\n", downstream.Step, target) + } +} + +func RenderBlast(w io.Writer, resp BlastRadiusResponse) { + fmt.Fprintf(w, "view_mode: %s\n", resp.ViewMode) + fmt.Fprintf(w, "key: %s\n", formatBlastKey(resp.Key)) + fmt.Fprintf(w, "affected_requests: %d\n", resp.AffectedRequests) + fmt.Fprintf(w, "affected_services: %d\n", resp.AffectedServices) + if resp.AffectedUsers == nil { + fmt.Fprintln(w, "affected_users: null") + } else { + fmt.Fprintf(w, "affected_users: %d\n", *resp.AffectedUsers) + } + fmt.Fprintf(w, "top_services: %s\n", strings.Join(resp.TopServices, ",")) + fmt.Fprintf(w, "sample_traces: %s\n", truncateList(resp.SampleTraces)) +} + +func RenderSearch(w io.Writer, resp EventSearchResponse) { + if len(resp.Events) == 0 { + fmt.Fprintln(w, "No events found.") + renderNextCursor(w, resp.NextCursor) + return + } + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "EVENT\tSTATUS\tSERVICE\tTRACE\tSTART") + for _, ev := range resp.Events { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", truncateID(ev.EventID), ev.Status, ev.Service, truncateID(ev.TraceID), formatTime(ev.TsStart)) + } + _ = tw.Flush() + renderNextCursor(w, resp.NextCursor) +} + +func renderNextCursor(w io.Writer, cursor *string) { + if cursor != nil && *cursor != "" { + fmt.Fprintf(w, "\nnext_cursor: %s\n", *cursor) + } +} + +func formatBlastKey(key BlastKey) string { + if key.Service == "" || key.Step == "" { + return key.ErrorCode + } + return apiv2.FormatErrorFamily(ErrorFamily{Service: key.Service, Step: key.Step, ErrorCode: key.ErrorCode}) +} + +func truncateList(ids []string) string { + if len(ids) == 0 { + return "" + } + out := make([]string, 0, len(ids)) + for _, id := range ids { + out = append(out, truncateID(id)) + } + return strings.Join(out, ",") +} + +func truncateID(id string) string { + if len(id) <= 15 { + return id + } + return id[:12] + "..." +} + +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} diff --git a/internal/cli/v2/render_test.go b/internal/cli/v2/render_test.go new file mode 100644 index 0000000..d0dbb2c --- /dev/null +++ b/internal/cli/v2/render_test.go @@ -0,0 +1,56 @@ +package cliv2 + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + apiv2 "github.com/sssmaran/WaylogCLI/pkg/api/v2" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +func TestRenderStoryPinsObservableLanguage(t *testing.T) { + var out bytes.Buffer + RenderStory(&out, StoryResponse{ + TraceID: "trace", + Service: "checkout", + Status: eventv2.StatusError, + Anchor: &StoryAnchor{Step: "payment.charge", ErrorCode: "PMT_502"}, + Path: []StoryStep{{Name: "payment.charge", Status: eventv2.StepStatusError, DurationMS: 12, ErrorMsg: "gateway"}}, + Linkage: apiv2.LinkageTimestampFallback, + }) + if !strings.Contains(out.String(), "first observable failing step") { + t.Fatalf("output missing required language:\n%s", out.String()) + } + if !strings.Contains(out.String(), "payment.charge -> PMT_502") { + t.Fatalf("output missing anchor:\n%s", out.String()) + } +} + +func TestRenderBlastPinsViewMode(t *testing.T) { + var out bytes.Buffer + RenderBlast(&out, BlastRadiusResponse{ViewMode: apiv2.BlastViewCrossFamily, Key: BlastKey{ErrorCode: "PMT_502"}}) + if !strings.Contains(out.String(), "view_mode: cross_family") { + t.Fatalf("output=%s", out.String()) + } +} + +func TestRenderJSONPrettyPrints(t *testing.T) { + var out bytes.Buffer + if err := renderJSON(&out, ErrorsResponse{Rows: []ErrorRow{}}); err != nil { + t.Fatal(err) + } + if !json.Valid(out.Bytes()) || !strings.Contains(out.String(), "\n ") { + t.Fatalf("json=%q", out.String()) + } +} + +func TestRenderNextCursor(t *testing.T) { + next := "abc" + var out bytes.Buffer + RenderSearch(&out, EventSearchResponse{NextCursor: &next}) + if !strings.Contains(out.String(), "next_cursor: abc") { + t.Fatalf("output=%s", out.String()) + } +} diff --git a/internal/cli/v2/types.go b/internal/cli/v2/types.go new file mode 100644 index 0000000..916b702 --- /dev/null +++ b/internal/cli/v2/types.go @@ -0,0 +1,62 @@ +package cliv2 + +import ( + "time" + + apiv2 "github.com/sssmaran/WaylogCLI/pkg/api/v2" +) + +type CapabilitiesResponse struct { + V2Reads struct { + Enabled bool `json:"enabled"` + } `json:"v2_reads"` +} + +type EventSearchResponse = apiv2.EventSearchResponse +type TraceGetResponse = apiv2.TraceGetResponse +type StoryResponse = apiv2.StoryResponse +type StoryAnchor = apiv2.StoryAnchor +type StoryStep = apiv2.StoryStep +type StoryLog = apiv2.StoryLog +type StoryDownstream = apiv2.StoryDownstream +type ErrorFamily = apiv2.ErrorFamily +type ErrorRow = apiv2.ErrorRow +type ErrorsResponse = apiv2.ErrorsResponse +type BlastKey = apiv2.BlastKey +type BlastRadiusResponse = apiv2.BlastRadiusResponse + +type ErrorsParams struct { + Window string + Service string + Limit int + Cursor string +} + +type StoryQuery struct { + EventID string + TraceID string +} + +type BlastParams struct { + Service string + Step string + ErrorCode string + ErrorFamily string + Window string +} + +type SearchParams struct { + ErrorCode string + TraceID string + Service string + Status string + Window string + Limit int + Cursor string +} + +type ClientConfig struct { + BaseURL string + APIKey string + Timeout time.Duration +} diff --git a/internal/ingest/handler.go b/internal/ingest/handler.go index d73f66a..6ad203e 100644 --- a/internal/ingest/handler.go +++ b/internal/ingest/handler.go @@ -141,7 +141,8 @@ type Server struct { // OTLP capability flag — reported by /v1/capabilities. Set via // ServerConfig when the OTLP handler is mounted in main.go. - otlpEnabled bool + otlpEnabled bool + v2ReadsEnabled bool // SSE sseHub *SSEHub @@ -206,6 +207,7 @@ type ServerConfig struct { PlanStore *PlanStore GraphHotWindow time.Duration OTLPEnabled bool + V2ReadsEnabled bool } // NewServer creates a new ingest server with the given configuration. @@ -244,6 +246,7 @@ func NewServer(cfg ServerConfig) *Server { planStore: cfg.PlanStore, graphHotWindow: cfg.GraphHotWindow, otlpEnabled: cfg.OTLPEnabled, + v2ReadsEnabled: cfg.V2ReadsEnabled, rateLimit: map[string][]time.Time{}, replayStatus: "none", } @@ -587,6 +590,9 @@ func (s *Server) Capabilities(w http.ResponseWriter, r *http.Request) { "otlp": map[string]any{ "http_traces": s.otlpEnabled, }, + "v2_reads": map[string]any{ + "enabled": s.v2ReadsEnabled, + }, "architecture": map[string]any{ "flattened": true, "graph": map[string]any{ diff --git a/internal/ingest/handler_test.go b/internal/ingest/handler_test.go index 483814a..a65fe3f 100644 --- a/internal/ingest/handler_test.go +++ b/internal/ingest/handler_test.go @@ -308,6 +308,9 @@ func TestCapabilities_Defaults(t *testing.T) { Dashboard struct { RefreshIntervalSec int `json:"refresh_interval_sec"` } `json:"dashboard"` + V2Reads struct { + Enabled bool `json:"enabled"` + } `json:"v2_reads"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("invalid json: %v", err) @@ -321,6 +324,29 @@ func TestCapabilities_Defaults(t *testing.T) { if resp.Dashboard.RefreshIntervalSec != 10 { t.Errorf("refresh_interval_sec = %d, want 10", resp.Dashboard.RefreshIntervalSec) } + if resp.V2Reads.Enabled { + t.Errorf("v2_reads.enabled = true, want false") + } +} + +func TestCapabilities_V2ReadsEnabled(t *testing.T) { + srv := NewServer(ServerConfig{V2ReadsEnabled: true}) + + req := httptest.NewRequest(http.MethodGet, "/v1/capabilities", nil) + w := httptest.NewRecorder() + srv.Capabilities(w, req) + + var resp struct { + V2Reads struct { + Enabled bool `json:"enabled"` + } `json:"v2_reads"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid json: %v", err) + } + if !resp.V2Reads.Enabled { + t.Fatal("v2_reads.enabled = false, want true") + } } const successTrace = "bbbb0000cccc1111dddd2222eeee3333" diff --git a/internal/ingest/v2/anchor.go b/internal/ingest/v2/anchor.go index a09e789..89015fd 100644 --- a/internal/ingest/v2/anchor.go +++ b/internal/ingest/v2/anchor.go @@ -2,7 +2,6 @@ package ingestv2 import ( "sort" - "strings" eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" ) @@ -27,7 +26,7 @@ func ResolveAnchorWithOptions(events []*eventv2.Event, opts ResolveOpts) AnchorR failed := make([]*eventv2.Event, 0, len(events)) for _, ev := range events { - if anchorCandidate(ev, opts) && isFailedStatus(ev.Status) { + if anchorCandidate(ev, opts) && ev.Status.IsFailed() { failed = append(failed, ev) } } @@ -146,15 +145,3 @@ func hasFailedDescendant(graph traceGraph, eventID string, failed map[string]str } return false } - -func anchorSummary(ev *eventv2.Event) *string { - if ev == nil || ev.Anchor == nil { - return nil - } - s := escapeAnchorPart(ev.Service) + ":" + escapeAnchorPart(ev.Anchor.Step) + ":" + escapeAnchorPart(ev.Anchor.ErrorCode) - return &s -} - -func escapeAnchorPart(s string) string { - return strings.ReplaceAll(s, ":", `\:`) -} diff --git a/internal/ingest/v2/derived.go b/internal/ingest/v2/derived.go index afc40eb..c246959 100644 --- a/internal/ingest/v2/derived.go +++ b/internal/ingest/v2/derived.go @@ -4,63 +4,19 @@ import ( "sort" "time" + apiv2 "github.com/sssmaran/WaylogCLI/pkg/api/v2" eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" ) -const LinkageDirect = "direct" +const LinkageDirect = apiv2.LinkageDirect -type StoryResponse struct { - TraceID string `json:"trace_id"` - Service string `json:"service"` - Route string `json:"route"` - Status eventv2.Status `json:"status"` - Anchor *StoryAnchor `json:"anchor"` - Path []StoryStep `json:"path"` - Logs []StoryLog `json:"logs"` - Downstream []StoryDownstream `json:"downstream"` - Linkage string `json:"linkage"` -} - -type StoryAnchor struct { - Step string `json:"step"` - ErrorCode string `json:"error_code"` -} - -type StoryStep struct { - Name string `json:"name"` - StartMS int64 `json:"start_ms"` - DurationMS int64 `json:"duration_ms"` - Status string `json:"status"` - ErrorCode string `json:"error_code,omitempty"` - ErrorMsg string `json:"error_msg,omitempty"` -} - -type StoryLog struct { - TsOffsetMS int64 `json:"ts_offset_ms"` - Level string `json:"level"` - Msg string `json:"msg"` - Step string `json:"step,omitempty"` -} - -type StoryDownstream struct { - Step string `json:"step"` - Service string `json:"service"` - Endpoint string `json:"endpoint"` -} - -type ErrorFamily struct { - Service string `json:"service"` - Step string `json:"step"` - ErrorCode string `json:"error_code"` -} - -type ErrorRow struct { - ErrorFamily ErrorFamily `json:"error_family"` - Count int `json:"count"` - AffectedUsers *int `json:"affected_users"` - AffectedTraces int `json:"affected_traces"` - SampleTraces []string `json:"sample_traces"` -} +type StoryResponse = apiv2.StoryResponse +type StoryAnchor = apiv2.StoryAnchor +type StoryStep = apiv2.StoryStep +type StoryLog = apiv2.StoryLog +type StoryDownstream = apiv2.StoryDownstream +type ErrorFamily = apiv2.ErrorFamily +type ErrorRow = apiv2.ErrorRow type ErrorsResult struct { Window string @@ -68,22 +24,8 @@ type ErrorsResult struct { NextCursor *ErrorCursor } -type BlastKey struct { - Service string `json:"service,omitempty"` - Step string `json:"step,omitempty"` - ErrorCode string `json:"error_code"` -} - -type BlastRadiusResult struct { - Key BlastKey `json:"key"` - ViewMode string `json:"view_mode"` - Window string `json:"window"` - AffectedRequests int `json:"affected_requests"` - AffectedUsers *int `json:"affected_users"` - AffectedServices int `json:"affected_services"` - TopServices []string `json:"top_services"` - SampleTraces []string `json:"sample_traces"` -} +type BlastKey = apiv2.BlastKey +type BlastRadiusResult = apiv2.BlastRadiusResponse type BlastKeyMode struct { Key BlastKey @@ -167,9 +109,9 @@ func (r *Reader) BlastRadius(f SearchFilter, key BlastKeyMode) BlastRadiusResult users := collectUsersForTraces(traces, matchedTraceLatest) services := countServicesForTraces(traces, matchedTraceLatest) affectedUsers := nullableCount(len(users), len(users) > 0) - viewMode := "single_family" + viewMode := apiv2.BlastViewSingleFamily if key.CrossCode { - viewMode = "cross_family" + viewMode = apiv2.BlastViewCrossFamily } return BlastRadiusResult{ Key: key.Key, @@ -216,7 +158,7 @@ func buildStory(ev *eventv2.Event, linkage string) StoryResponse { firstStart = steps[0].StartMS } for _, log := range ev.Logs { - if log.Level != "warn" && log.Level != "error" { + if log.Level != eventv2.LogLevelWarn && log.Level != eventv2.LogLevelError { continue } if anchored && (log.TsOffsetMS < firstStart || log.TsOffsetMS > anchorEnd) { @@ -262,7 +204,7 @@ func latestAnchorStep(steps []eventv2.Step, name string) (eventv2.Step, bool) { fallback = step hasFallback = true } - if step.Status == "error" && (!hasLatest || step.StartMS > latest.StartMS) { + if step.Status == eventv2.StepStatusError && (!hasLatest || step.StartMS > latest.StartMS) { latest = step hasLatest = true } @@ -329,7 +271,7 @@ func aggregateErrorRows(events []*eventv2.Event, traces map[string][]*eventv2.Ev } func eventMatchesErrorRollup(ev *eventv2.Event, f SearchFilter) bool { - if ev == nil || ev.Anchor == nil || !isFailedStatus(ev.Status) { + if ev == nil || ev.Anchor == nil || !ev.Status.IsFailed() { return false } if len(f.Statuses) > 0 { @@ -344,7 +286,7 @@ func eventMatchesErrorRollup(ev *eventv2.Event, f SearchFilter) bool { } func eventMatchesBlastKey(ev *eventv2.Event, f SearchFilter, key BlastKeyMode) bool { - if ev == nil || ev.Anchor == nil || !isFailedStatus(ev.Status) || !eventWithinWindow(ev, f) { + if ev == nil || ev.Anchor == nil || !ev.Status.IsFailed() || !eventWithinWindow(ev, f) { return false } if key.Key.ErrorCode != ev.Anchor.ErrorCode { @@ -496,39 +438,6 @@ func routeFromV2Fields(fields map[string]any) string { return route } -func parseErrorFamilyDisplay(s string) (BlastKey, bool) { - parts := make([]string, 0, 3) - var current []rune - escaped := false - for _, r := range s { - if escaped { - if r != ':' && r != '\\' { - return BlastKey{}, false - } - current = append(current, r) - escaped = false - continue - } - switch r { - case '\\': - escaped = true - case ':': - parts = append(parts, string(current)) - current = current[:0] - default: - current = append(current, r) - } - } - if escaped { - return BlastKey{}, false - } - parts = append(parts, string(current)) - if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { - return BlastKey{}, false - } - return BlastKey{Service: parts[0], Step: parts[1], ErrorCode: parts[2]}, true -} - func timeWindowString(since, until time.Time) string { if since.IsZero() || until.IsZero() || until.Before(since) { return "" diff --git a/internal/ingest/v2/derived_test.go b/internal/ingest/v2/derived_test.go index 7c6be7a..a6c1d87 100644 --- a/internal/ingest/v2/derived_test.go +++ b/internal/ingest/v2/derived_test.go @@ -6,13 +6,14 @@ import ( "testing" "time" + apiv2 "github.com/sssmaran/WaylogCLI/pkg/api/v2" eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" ) func TestTraceStoryDirectSuppressedIsHeaderOnly(t *testing.T) { idx := NewRecentIndex(nil) ev := testTraceEvent("suppressed", "trace", "checkout", eventv2.StatusSuppressed, testTime(0)) - ev.Steps = []eventv2.Step{{Name: "hidden", StartMS: 0, DurationMS: 1, Status: "ok"}} + ev.Steps = []eventv2.Step{{Name: "hidden", StartMS: 0, DurationMS: 1, Status: eventv2.StepStatusOK}} idx.Insert(ev) story, ok := NewReader(idx).TraceStoryByEventID("suppressed") @@ -35,15 +36,15 @@ func TestTraceStoryByTraceExcludesSuppressedAndBuildsContributingWindow(t *testi okChild.Fields = map[string]any{"http": map[string]any{"route": "/buy"}} okChild.Anchor = &eventv2.Anchor{Step: "pay", ErrorCode: "PMT_502"} okChild.Steps = []eventv2.Step{ - {Name: "prepare", StartMS: 0, DurationMS: 5, Status: "ok"}, - {Name: "pay", StartMS: 10, DurationMS: 5, Status: "error", Error: &eventv2.StepError{Code: "PMT_502", Reason: "gateway"}}, - {Name: "pay", StartMS: 20, DurationMS: 5, Status: "error", Error: &eventv2.StepError{Code: "PMT_502", Reason: "final"}}, - {Name: "cleanup", StartMS: 30, DurationMS: 5, Status: "ok"}, + {Name: "prepare", StartMS: 0, DurationMS: 5, Status: eventv2.StepStatusOK}, + {Name: "pay", StartMS: 10, DurationMS: 5, Status: eventv2.StepStatusError, Error: &eventv2.StepError{Code: "PMT_502", Reason: "gateway"}}, + {Name: "pay", StartMS: 20, DurationMS: 5, Status: eventv2.StepStatusError, Error: &eventv2.StepError{Code: "PMT_502", Reason: "final"}}, + {Name: "cleanup", StartMS: 30, DurationMS: 5, Status: eventv2.StepStatusOK}, } okChild.Logs = []eventv2.Log{ - {TsOffsetMS: 4, Level: "warn", Msg: "early"}, - {TsOffsetMS: 24, Level: "error", Msg: "anchor"}, - {TsOffsetMS: 31, Level: "error", Msg: "late"}, + {TsOffsetMS: 4, Level: eventv2.LogLevelWarn, Msg: "early"}, + {TsOffsetMS: 24, Level: eventv2.LogLevelError, Msg: "anchor"}, + {TsOffsetMS: 31, Level: eventv2.LogLevelError, Msg: "late"}, } idx.Insert(suppressedRoot) idx.Insert(okChild) @@ -102,7 +103,7 @@ func TestBlastRadiusKeyModesAndCounts(t *testing.T) { reader := NewReader(idx) filter := SearchFilter{Since: testTime(0), Until: testTime(10)} single := reader.BlastRadius(filter, BlastKeyMode{Key: BlastKey{Service: "checkout", Step: "charge", ErrorCode: "PMT_502"}}) - if single.ViewMode != "single_family" || single.AffectedRequests != 1 || single.AffectedServices != 2 { + if single.ViewMode != apiv2.BlastViewSingleFamily || single.AffectedRequests != 1 || single.AffectedServices != 2 { t.Fatalf("single=%+v", single) } if single.AffectedUsers == nil || *single.AffectedUsers != 1 { @@ -113,7 +114,7 @@ func TestBlastRadiusKeyModesAndCounts(t *testing.T) { } cross := reader.BlastRadius(filter, BlastKeyMode{Key: BlastKey{ErrorCode: "PMT_502"}, CrossCode: true}) - if cross.ViewMode != "cross_family" || cross.AffectedRequests != 2 || cross.AffectedServices != 3 { + if cross.ViewMode != apiv2.BlastViewCrossFamily || cross.AffectedRequests != 2 || cross.AffectedServices != 3 { t.Fatalf("cross=%+v", cross) } } @@ -161,7 +162,7 @@ func TestReadHandlerDerivedEndpoints(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &blast); err != nil { t.Fatal(err) } - if blast.ViewMode != "single_family" || blast.Key.Service != "checkout" || blast.AffectedRequests != 1 { + if blast.ViewMode != apiv2.BlastViewSingleFamily || blast.Key.Service != "checkout" || blast.AffectedRequests != 1 { t.Fatalf("blast=%+v", blast) } @@ -170,12 +171,12 @@ func TestReadHandlerDerivedEndpoints(t *testing.T) { } func TestErrorFamilyDisplayParser(t *testing.T) { - key, ok := parseErrorFamilyDisplay(`svc\:a:step\:b:CODE`) + key, ok := ParseErrorFamily(`svc\:a:step\:b:CODE`) if !ok || key.Service != "svc:a" || key.Step != "step:b" || key.ErrorCode != "CODE" { t.Fatalf("key=%+v ok=%v", key, ok) } for _, raw := range []string{"a:b", `a:b:c\`, `a:b:c:d`, `a:\x:c`} { - if _, ok := parseErrorFamilyDisplay(raw); ok { + if _, ok := ParseErrorFamily(raw); ok { t.Fatalf("expected malformed: %q", raw) } } @@ -184,7 +185,7 @@ func TestErrorFamilyDisplayParser(t *testing.T) { func errorEvent(id, traceID, service, step, code string, ts time.Time, userID string) *eventv2.Event { ev := testTraceEvent(id, traceID, service, eventv2.StatusError, ts) ev.Anchor = &eventv2.Anchor{Step: step, ErrorCode: code} - ev.Steps = []eventv2.Step{{Name: step, StartMS: 0, DurationMS: 10, Status: "error", Error: &eventv2.StepError{Code: code, Reason: "failed"}}} + ev.Steps = []eventv2.Step{{Name: step, StartMS: 0, DurationMS: 10, Status: eventv2.StepStatusError, Error: &eventv2.StepError{Code: code, Reason: "failed"}}} if userID != "" { ev.Fields = map[string]any{"user": map[string]any{"id": userID}} } diff --git a/internal/ingest/v2/errorfamily.go b/internal/ingest/v2/errorfamily.go new file mode 100644 index 0000000..af74514 --- /dev/null +++ b/internal/ingest/v2/errorfamily.go @@ -0,0 +1,22 @@ +package ingestv2 + +import ( + apiv2 "github.com/sssmaran/WaylogCLI/pkg/api/v2" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +func FormatErrorFamily(f ErrorFamily) string { + return apiv2.FormatErrorFamily(f) +} + +func FormatEventErrorFamily(ev *eventv2.Event) *string { + if ev == nil || ev.Anchor == nil { + return nil + } + s := FormatErrorFamily(ErrorFamily{Service: ev.Service, Step: ev.Anchor.Step, ErrorCode: ev.Anchor.ErrorCode}) + return &s +} + +func ParseErrorFamily(s string) (BlastKey, bool) { + return apiv2.ParseErrorFamily(s) +} diff --git a/internal/ingest/v2/errorfamily_test.go b/internal/ingest/v2/errorfamily_test.go new file mode 100644 index 0000000..7e785e6 --- /dev/null +++ b/internal/ingest/v2/errorfamily_test.go @@ -0,0 +1,29 @@ +package ingestv2 + +import "testing" + +func TestErrorFamilyRoundTrip(t *testing.T) { + cases := []ErrorFamily{ + {Service: "svc", Step: "step", ErrorCode: "CODE"}, + {Service: "svc:a", Step: "step:b", ErrorCode: "CODE"}, + {Service: "svc", Step: "step", ErrorCode: "CODE:WITH:COLON"}, + } + for _, tc := range cases { + display := FormatErrorFamily(tc) + key, ok := ParseErrorFamily(display) + if !ok { + t.Fatalf("ParseErrorFamily(%q) failed", display) + } + if key.Service != tc.Service || key.Step != tc.Step || key.ErrorCode != tc.ErrorCode { + t.Fatalf("round trip=%+v want %+v display=%q", key, tc, display) + } + } +} + +func TestParseErrorFamilyRejectsMalformed(t *testing.T) { + for _, raw := range []string{"a:b", `a:b:c\`, `a:b:c:d`, `a:\x:c`, ":b:c", "a::c", "a:b:"} { + if _, ok := ParseErrorFamily(raw); ok { + t.Fatalf("expected malformed: %q", raw) + } + } +} diff --git a/internal/ingest/v2/index.go b/internal/ingest/v2/index.go index b9b9d5f..142051d 100644 --- a/internal/ingest/v2/index.go +++ b/internal/ingest/v2/index.go @@ -216,7 +216,7 @@ func (idx *RecentIndex) applyAggregatesLocked(ev *eventv2.Event) { return } - if isFailedStatus(ev.Status) && ev.Anchor != nil { + if ev.Status.IsFailed() && ev.Anchor != nil { key := ErrorKey{Service: ev.Service, Step: ev.Anchor.Step, ErrorCode: ev.Anchor.ErrorCode} node := idx.errors[key] if node == nil { @@ -288,15 +288,6 @@ func touchRange(first, last *time.Time, ts time.Time) { } } -func isFailedStatus(status eventv2.Status) bool { - switch status { - case eventv2.StatusError, eventv2.StatusTimeout, eventv2.StatusPartial, eventv2.StatusAborted: - return true - default: - return false - } -} - func cloneEvent(ev *eventv2.Event) *eventv2.Event { if ev == nil { return nil diff --git a/internal/ingest/v2/linkage.go b/internal/ingest/v2/linkage.go index b13c26f..43cb370 100644 --- a/internal/ingest/v2/linkage.go +++ b/internal/ingest/v2/linkage.go @@ -3,12 +3,13 @@ package ingestv2 import ( "sort" + apiv2 "github.com/sssmaran/WaylogCLI/pkg/api/v2" eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" ) const ( - LinkageCausal = "causal" - LinkageTimestampFallback = "timestamp_fallback" + LinkageCausal = apiv2.LinkageCausal + LinkageTimestampFallback = apiv2.LinkageTimestampFallback ) func OrderTrace(events []*eventv2.Event) ([]*eventv2.Event, string) { diff --git a/internal/ingest/v2/reader.go b/internal/ingest/v2/reader.go index 5758efd..c8bd848 100644 --- a/internal/ingest/v2/reader.go +++ b/internal/ingest/v2/reader.go @@ -4,6 +4,7 @@ import ( "sort" "time" + apiv2 "github.com/sssmaran/WaylogCLI/pkg/api/v2" eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" ) @@ -36,14 +37,7 @@ type TraceGetResult struct { Linkage string } -type TraceSummary struct { - TraceID string `json:"trace_id"` - TsStart time.Time `json:"ts_start"` - DurationMS int64 `json:"duration_ms"` - Services []string `json:"services"` - Status eventv2.Status `json:"status"` - AnchorSummary *string `json:"anchor_summary"` -} +type TraceSummary = apiv2.TraceSummary type RecentTracesResult struct { Traces []TraceSummary @@ -116,7 +110,7 @@ func (r *Reader) RecentTraces(f SearchFilter, after *TraceCursor, limit int) Rec summaries := make([]TraceSummary, 0, len(groups)) for traceID, events := range groups { if traceHasMatchingEvent(events, f) { - summary, ok := buildTraceSummary(traceID, events) + summary, ok := buildTraceSummary(traceID, events, f.IncludeSuppressed) if ok { summaries = append(summaries, summary) } @@ -189,7 +183,7 @@ func traceHasMatchingEvent(events []*eventv2.Event, f SearchFilter) bool { return false } -func buildTraceSummary(traceID string, events []*eventv2.Event) (TraceSummary, bool) { +func buildTraceSummary(traceID string, events []*eventv2.Event, includeSuppressed bool) (TraceSummary, bool) { if len(events) == 0 { return TraceSummary{}, false } @@ -221,11 +215,11 @@ func buildTraceSummary(traceID string, events []*eventv2.Event) (TraceSummary, b seenServices[ev.Service] = struct{}{} services = append(services, ev.Service) } - resolved := ResolveAnchor(events) - status := eventv2.Status("") - if resolved.Event != nil { - status = resolved.Event.Status + resolved := ResolveAnchorWithOptions(events, ResolveOpts{ExcludeSuppressed: !includeSuppressed}) + if resolved.Event == nil { + return TraceSummary{}, false } + status := resolved.Event.Status duration := int64(0) if !minStart.IsZero() && !maxEnd.IsZero() && maxEnd.After(minStart) { duration = maxEnd.Sub(minStart).Milliseconds() @@ -236,7 +230,7 @@ func buildTraceSummary(traceID string, events []*eventv2.Event) (TraceSummary, b DurationMS: duration, Services: services, Status: status, - AnchorSummary: anchorSummary(resolved.Event), + AnchorSummary: FormatEventErrorFamily(resolved.Event), }, true } diff --git a/internal/ingest/v2/reader_test.go b/internal/ingest/v2/reader_test.go index c616709..e741cb4 100644 --- a/internal/ingest/v2/reader_test.go +++ b/internal/ingest/v2/reader_test.go @@ -60,7 +60,7 @@ func TestSearchEventsPaginatesWithCursor(t *testing.T) { func TestRecentTracesUsesAnyEventFilterAndFullTraceSummary(t *testing.T) { idx := NewRecentIndex(nil) root := testTraceEvent("root", "trace", "gateway", eventv2.StatusOK, testTime(0)) - root.Steps = []eventv2.Step{{Name: "call.payment", SpanID: "span-payment", Status: "ok"}} + root.Steps = []eventv2.Step{{Name: "call.payment", SpanID: "span-payment", Status: eventv2.StepStatusOK}} payment := testTraceEvent("payment", "trace", "payment", eventv2.StatusError, testTime(1)) payment.ParentSpanID = "span-payment" payment.Anchor = &eventv2.Anchor{Step: "charge", ErrorCode: "PMT:502"} @@ -98,7 +98,7 @@ func TestRecentTracesSuppressedOnlyRequiresOptIn(t *testing.T) { filter.IncludeSuppressed = true got := reader.RecentTraces(filter, nil, 10) if len(got.Traces) != 1 || got.Traces[0].Status != eventv2.StatusSuppressed { - t.Fatalf("traces=%+v", got.Traces) + t.Fatalf("traces=%+v want suppressed-only trace when explicitly included", got.Traces) } } diff --git a/internal/ingest/v2/readhandler.go b/internal/ingest/v2/readhandler.go index 79cbb59..b6341ca 100644 --- a/internal/ingest/v2/readhandler.go +++ b/internal/ingest/v2/readhandler.go @@ -8,6 +8,7 @@ import ( "time" "github.com/sssmaran/WaylogCLI/internal/metrics" + apiv2 "github.com/sssmaran/WaylogCLI/pkg/api/v2" eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" ) @@ -330,27 +331,10 @@ type eventGetResponse struct { Event *eventv2.Event `json:"event"` } -type eventSearchResponse struct { - Events []*eventv2.Event `json:"events"` - NextCursor *string `json:"next_cursor"` -} - -type traceGetResponse struct { - TraceID string `json:"trace_id"` - Events []*eventv2.Event `json:"events"` - Linkage string `json:"linkage"` -} - -type recentTracesResponse struct { - Traces []TraceSummary `json:"traces"` - NextCursor *string `json:"next_cursor"` -} - -type errorsResponse struct { - Window string `json:"window"` - Rows []ErrorRow `json:"rows"` - NextCursor *string `json:"next_cursor"` -} +type eventSearchResponse = apiv2.EventSearchResponse +type traceGetResponse = apiv2.TraceGetResponse +type recentTracesResponse = apiv2.RecentTracesResponse +type errorsResponse = apiv2.ErrorsResponse type readErrorBody struct { Error readErrorDetail `json:"error"` @@ -450,7 +434,7 @@ func parseErrorStatuses(q url.Values) (map[eventv2.Status]struct{}, *queryError) return nil, err } for status := range statuses { - if !isFailedStatus(status) { + if !status.IsFailed() { return nil, &queryError{code: errorCodeBadRequest, message: "bad request", detail: "status must be one of error, timeout, partial, aborted"} } } @@ -466,7 +450,7 @@ func parseBlastKey(q url.Values) (BlastKeyMode, bool) { return BlastKeyMode{Key: BlastKey{Service: service, Step: step, ErrorCode: code}}, true } if display := q.Get("error_family"); display != "" { - key, ok := parseErrorFamilyDisplay(display) + key, ok := ParseErrorFamily(display) if !ok { return BlastKeyMode{}, false } diff --git a/internal/ingest/v2/readhandler_test.go b/internal/ingest/v2/readhandler_test.go index 366a979..6fc44a1 100644 --- a/internal/ingest/v2/readhandler_test.go +++ b/internal/ingest/v2/readhandler_test.go @@ -69,7 +69,7 @@ func TestReadHandlerRejectsWindowOverHotWindowAndOldSince(t *testing.T) { func TestReadHandlerTraceGetAndRecent(t *testing.T) { h := newTestReadHandler(t, nil) root := testTraceEvent("root", "trace", "gateway", eventv2.StatusOK, testTime(0)) - root.Steps = []eventv2.Step{{Name: "call.payment", SpanID: "span-payment", Status: "ok"}} + root.Steps = []eventv2.Step{{Name: "call.payment", SpanID: "span-payment", Status: eventv2.StepStatusOK}} payment := testTraceEvent("payment", "trace", "payment", eventv2.StatusError, testTime(1)) payment.ParentSpanID = "span-payment" payment.Anchor = &eventv2.Anchor{Step: "charge", ErrorCode: "PMT_502"} diff --git a/pkg/api/v2/types.go b/pkg/api/v2/types.go new file mode 100644 index 0000000..9fd4a79 --- /dev/null +++ b/pkg/api/v2/types.go @@ -0,0 +1,161 @@ +// Package apiv2 defines the schema-v2 read API response contracts shared by +// the ingest server and the operator CLI. +package apiv2 + +import ( + "strings" + "time" + + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +const ( + LinkageCausal = "causal" + LinkageTimestampFallback = "timestamp_fallback" + LinkageDirect = "direct" + + BlastViewSingleFamily = "single_family" + BlastViewCrossFamily = "cross_family" +) + +type EventSearchResponse struct { + Events []*eventv2.Event `json:"events"` + NextCursor *string `json:"next_cursor"` +} + +type TraceGetResponse struct { + TraceID string `json:"trace_id"` + Events []*eventv2.Event `json:"events"` + Linkage string `json:"linkage"` +} + +type TraceSummary struct { + TraceID string `json:"trace_id"` + TsStart time.Time `json:"ts_start"` + DurationMS int64 `json:"duration_ms"` + Services []string `json:"services"` + Status eventv2.Status `json:"status"` + AnchorSummary *string `json:"anchor_summary"` +} + +type RecentTracesResponse struct { + Traces []TraceSummary `json:"traces"` + NextCursor *string `json:"next_cursor"` +} + +type StoryResponse struct { + TraceID string `json:"trace_id"` + Service string `json:"service"` + Route string `json:"route"` + Status eventv2.Status `json:"status"` + Anchor *StoryAnchor `json:"anchor"` + Path []StoryStep `json:"path"` + Logs []StoryLog `json:"logs"` + Downstream []StoryDownstream `json:"downstream"` + Linkage string `json:"linkage"` +} + +type StoryAnchor struct { + Step string `json:"step"` + ErrorCode string `json:"error_code"` +} + +type StoryStep struct { + Name string `json:"name"` + StartMS int64 `json:"start_ms"` + DurationMS int64 `json:"duration_ms"` + Status eventv2.StepStatus `json:"status"` + ErrorCode string `json:"error_code,omitempty"` + ErrorMsg string `json:"error_msg,omitempty"` +} + +type StoryLog struct { + TsOffsetMS int64 `json:"ts_offset_ms"` + Level eventv2.LogLevel `json:"level"` + Msg string `json:"msg"` + Step string `json:"step,omitempty"` +} + +type StoryDownstream struct { + Step string `json:"step"` + Service string `json:"service"` + Endpoint string `json:"endpoint"` +} + +type ErrorFamily struct { + Service string `json:"service"` + Step string `json:"step"` + ErrorCode string `json:"error_code"` +} + +type ErrorRow struct { + ErrorFamily ErrorFamily `json:"error_family"` + Count int `json:"count"` + AffectedUsers *int `json:"affected_users"` + AffectedTraces int `json:"affected_traces"` + SampleTraces []string `json:"sample_traces"` +} + +type ErrorsResponse struct { + Window string `json:"window"` + Rows []ErrorRow `json:"rows"` + NextCursor *string `json:"next_cursor"` +} + +type BlastKey struct { + Service string `json:"service,omitempty"` + Step string `json:"step,omitempty"` + ErrorCode string `json:"error_code"` +} + +type BlastRadiusResponse struct { + Key BlastKey `json:"key"` + ViewMode string `json:"view_mode"` + Window string `json:"window"` + AffectedRequests int `json:"affected_requests"` + AffectedUsers *int `json:"affected_users"` + AffectedServices int `json:"affected_services"` + TopServices []string `json:"top_services"` + SampleTraces []string `json:"sample_traces"` +} + +func FormatErrorFamily(f ErrorFamily) string { + return escapeErrorFamilyPart(f.Service) + ":" + escapeErrorFamilyPart(f.Step) + ":" + escapeErrorFamilyPart(f.ErrorCode) +} + +func ParseErrorFamily(s string) (BlastKey, bool) { + parts := make([]string, 0, 3) + var current []rune + escaped := false + for _, r := range s { + if escaped { + if r != ':' && r != '\\' { + return BlastKey{}, false + } + current = append(current, r) + escaped = false + continue + } + switch r { + case '\\': + escaped = true + case ':': + parts = append(parts, string(current)) + current = current[:0] + default: + current = append(current, r) + } + } + if escaped { + return BlastKey{}, false + } + parts = append(parts, string(current)) + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return BlastKey{}, false + } + return BlastKey{Service: parts[0], Step: parts[1], ErrorCode: parts[2]}, true +} + +func escapeErrorFamilyPart(s string) string { + return strings.ReplaceAll(s, ":", `\:`) +} diff --git a/pkg/api/v2/types_test.go b/pkg/api/v2/types_test.go new file mode 100644 index 0000000..3bee642 --- /dev/null +++ b/pkg/api/v2/types_test.go @@ -0,0 +1,29 @@ +package apiv2 + +import "testing" + +func TestErrorFamilyRoundTrip(t *testing.T) { + cases := []ErrorFamily{ + {Service: "svc", Step: "step", ErrorCode: "CODE"}, + {Service: "svc:a", Step: "step:b", ErrorCode: "CODE"}, + {Service: "svc", Step: "step", ErrorCode: "CODE:WITH:COLON"}, + } + for _, tc := range cases { + display := FormatErrorFamily(tc) + key, ok := ParseErrorFamily(display) + if !ok { + t.Fatalf("ParseErrorFamily(%q) failed", display) + } + if key.Service != tc.Service || key.Step != tc.Step || key.ErrorCode != tc.ErrorCode { + t.Fatalf("round trip=%+v want %+v display=%q", key, tc, display) + } + } +} + +func TestParseErrorFamilyRejectsMalformed(t *testing.T) { + for _, raw := range []string{"a:b", `a:b:c\`, `a:b:c:d`, `a:\x:c`, ":b:c", "a::c", "a:b:"} { + if _, ok := ParseErrorFamily(raw); ok { + t.Fatalf("expected malformed: %q", raw) + } + } +} diff --git a/pkg/event/v2/event.go b/pkg/event/v2/event.go index 43fd89d..94bce06 100644 --- a/pkg/event/v2/event.go +++ b/pkg/event/v2/event.go @@ -34,6 +34,25 @@ func (s Status) IsPriority() bool { } } +func (s Status) IsFailed() bool { + return s.IsPriority() +} + +type StepStatus = string + +const ( + StepStatusOK StepStatus = "ok" + StepStatusError StepStatus = "error" +) + +type LogLevel = string + +const ( + LogLevelInfo LogLevel = "info" + LogLevelWarn LogLevel = "warn" + LogLevelError LogLevel = "error" +) + const ( CodeTimeout = "WAYLOG_TIMEOUT" CodeAborted = "WAYLOG_ABORTED" diff --git a/pkg/event/v2/event_test.go b/pkg/event/v2/event_test.go index d16f74b..5f54b9e 100644 --- a/pkg/event/v2/event_test.go +++ b/pkg/event/v2/event_test.go @@ -45,3 +45,17 @@ func TestStatusConstants(t *testing.T) { } } } + +func TestStatusIsFailed(t *testing.T) { + failed := []Status{StatusError, StatusTimeout, StatusPartial, StatusAborted} + for _, status := range failed { + if !status.IsFailed() { + t.Fatalf("%s should be failed", status) + } + } + for _, status := range []Status{StatusOK, StatusSuppressed} { + if status.IsFailed() { + t.Fatalf("%s should not be failed", status) + } + } +} From 90ecc77c13c2f9b8250adba4881a5ff50b54266c Mon Sep 17 00:00:00 2001 From: skota-hash Date: Wed, 29 Apr 2026 03:45:27 -0400 Subject: [PATCH 06/12] feat(demo): rewrite reference demo for schema 2.0 Rewrite the local reference demo as an event-driven schema-2.0 mini-commerce flow using the Go v2 SDK. The demo services now emit via pkg/waylog/v2 and pkg/waylog/http during real HTTP request flows across api-gateway, checkout, db, and payment. The demo supports three deterministic scenarios: happy, payment_502, and suppressed_payment_502. Key behavior: - api-gateway exposes GET /demo and POST /purchase. - checkout is the primary narrative event for payment_502. - checkout records db.load_cart and payment.charge steps. - payment.charge carries downstream linkage to payment. - checkout records retry/failure logs and anchor payment.charge:PMT_502. - suppressed_payment_502 emits suppressed events for the known issue path. - payment returns HTTP 502 without stealing the trace-story failure anchor, keeping checkout as the first observable failing step. Remove Kafka and cmd/bridge from the local demo path. make demo and make micro-demo now start ingest plus the four demo services with WAYLOG_V2_READS=true. Kafka targets and bridge code remain available for legacy paths and are not deleted in this slice. Update the demo UI with scenario buttons and accessible interaction basics. Update the smoke script, README, docs/env.md, and CHANGELOG to describe the schema-2.0 demo flow and v2 CLI investigation path. Add v2-focused microdemo tests using SDK local Output buffers. The tests pin checkout event shape, payment_502 narrative behavior, suppressed header-only events, downstream linkage, logs, route/user/demo fields, and the gateway UI. Verification: - make build - make test - go test -race ./examples/microdemo ./pkg/waylog/v2/... - go vet ./... - git diff --check - manual live smoke with waylog errors/explain/blast --- Makefile | 8 +- README.md | 34 ++- docs/env.md | 13 ++ examples/cmd/api-gateway/main.go | 73 +----- examples/cmd/checkout-demo/main.go | 74 +----- examples/cmd/db-demo/main.go | 76 +------ examples/cmd/payment-demo/main.go | 76 +------ examples/microdemo/checkout.go | 153 ++++++------- examples/microdemo/db.go | 44 ++-- examples/microdemo/gateway.go | 101 +++++---- examples/microdemo/microdemo_test.go | 327 ++++++++++++++++++--------- examples/microdemo/payment.go | 91 +++----- examples/microdemo/scenario.go | 60 +++++ examples/microdemo/service.go | 49 ++++ examples/microdemo/ui.html | 227 +++++++++++++++++-- scripts/demo-stop.sh | 9 +- scripts/demo.sh | 65 +----- scripts/micro-demo-smoke.sh | 56 +++-- scripts/micro-demo-stop.sh | 6 +- scripts/micro-demo.sh | 72 +++--- 20 files changed, 858 insertions(+), 756 deletions(-) create mode 100644 examples/microdemo/scenario.go create mode 100644 examples/microdemo/service.go diff --git a/Makefile b/Makefile index 1db8e11..8d029c3 100644 --- a/Makefile +++ b/Makefile @@ -16,8 +16,8 @@ help: @echo " clean - remove build outputs" @echo " kafka-up - start local Kafka via docker compose" @echo " kafka-down - stop local Kafka via docker compose" - @echo " demo - start Kafka + demo flow (single terminal)" - @echo " demo-stop - stop Kafka + demo processes" + @echo " demo - start schema-2.0 micro-demo (single terminal)" + @echo " demo-stop - stop demo processes" @echo " micro-demo - start 4-service micro-demo (gateway+checkout+db+payment)" @echo " micro-demo-stop - stop micro-demo processes" @echo " waylog-live - run TUI dashboard (connects to ingest server)" @@ -112,13 +112,13 @@ kafka-down: docker compose -f docker-compose.kafka.yml down -v demo: - START_KAFKA=1 ./scripts/demo.sh + ./scripts/demo.sh demo-stop: ./scripts/demo-stop.sh micro-demo: - START_KAFKA=1 ./scripts/micro-demo.sh + ./scripts/micro-demo.sh micro-demo-stop: ./scripts/micro-demo-stop.sh diff --git a/README.md b/README.md index 8a84e25..bef43dc 100644 --- a/README.md +++ b/README.md @@ -104,22 +104,32 @@ Point your existing OpenTelemetry collector at `http://localhost:8080/v1/otlp/v1 ## Quick start ```bash -make docker-dev +make build +make micro-demo ``` -This starts the ingest server, embedded dashboard, Prometheus, Grafana, and four real Go services wired together with the Waylog SDK middleware (`api-gateway → checkout → db → payment`). No mocks. +This starts the ingest server plus four real Go demo services wired through the schema-2.0 Go SDK (`api-gateway → checkout → db/payment`). The local demo path no longer needs Kafka or the bridge process. Once the stack is up: -1. Open the demo app at and click a button: - - **Purchase (Success)** — healthy 4-service flow - - **Purchase (DB Fail)** — `DB_503` cascading up through checkout and the gateway - - **Purchase (Payment Fail)** — `PMT_502` cascading up through checkout - - **Purchase (Checkout Fail)** — `CHK_500` short-circuit at checkout -2. Open the dashboard at — KPIs, failing-traces banner, recent traces, and deploy-diff panel populate live via SSE. -3. Click into a failing trace to see both the flat propagation chain and the rendered tree. - -Stop with `make docker-down`. Wipe persistent volumes with `make docker-reset`. +1. Open the demo app at . +2. Click **Payment gateway 502** or run: + ```bash + curl -s -X POST http://localhost:9081/purchase \ + -H 'Content-Type: application/json' \ + --data '{"sku":"X1","scenario":"payment_502"}' + ``` +3. Investigate with the v2 CLI: + ```bash + WAYLOG_READ_KEY=demo ./waylog errors --window 15m + WAYLOG_READ_KEY=demo ./waylog explain + WAYLOG_READ_KEY=demo ./waylog blast --service checkout --step payment.charge --code PMT_502 --window 15m + WAYLOG_READ_KEY=demo ./waylog blast --code PMT_502 --window 15m + ``` + +The demo also supports `happy` and `suppressed_payment_502` scenarios through the UI or `POST /purchase`. + +Stop with `make micro-demo-stop`. > `./scripts/demo-cascade-failure.sh` injects an equivalent fixture by POSTing synthetic events directly. It is a fixture, not a substitute for the live path above. @@ -306,4 +316,4 @@ Public alpha. APIs may break before 1.0. - No built-in alerting or paging. Waylog answers questions, it doesn't wake you up. - No multi-tenancy. One instance = one trust boundary. -**Fastest walkthrough:** `make docker-dev`, open , click a failure button, then open to see the propagation chain live. +**Fastest walkthrough:** `make micro-demo`, open , click **Payment gateway 502**, then use `waylog errors`, `waylog explain`, and `waylog blast` to answer what failed, which downstream was involved, and how broad the impact is. diff --git a/docs/env.md b/docs/env.md index 1a90ce0..4d91a6b 100644 --- a/docs/env.md +++ b/docs/env.md @@ -80,6 +80,19 @@ See [Internals](internals.md) for the full durability model. | `HAPPY_SAMPLE_RATE_PCT` | `2` | Success-event sampling rate. Set `100` in dev profiles | | `MCP_STDIO` | — | Set to `1` to run MCP stdio server instead of REPL | +## Schema-2.0 reference demo + +`make micro-demo` sets these automatically for the local event-driven demo. + +| Variable | Default | Purpose | +|---|---|---| +| `INGEST_URL` | `http://localhost:8080` | SDK delivery base URL used by the demo services | +| `INGEST_ADDR` | `:8080` | Ingest server listen address | +| `WAYLOG_WRITE_KEY` | `demo` | Write-scope key used by the demo SDK emitters | +| `WAYLOG_READ_KEY` | `demo` | Read-scope key used by the printed CLI commands | +| `DASHBOARD_AUTH` | `key:demo` | Enables dashboard/read-session auth so `WAYLOG_READ_KEY` is valid at startup | +| `WAYLOG_V2_READS` | `true` in demo scripts | Enables v2 read APIs required by `waylog errors/explain/blast` | + ## Dashboard links Optional external links rendered in the dashboard header. Hidden if empty. diff --git a/examples/cmd/api-gateway/main.go b/examples/cmd/api-gateway/main.go index a7a20af..513cae9 100644 --- a/examples/cmd/api-gateway/main.go +++ b/examples/cmd/api-gateway/main.go @@ -1,55 +1,17 @@ package main import ( - "context" "log/slog" "net/http" "os" - "os/signal" - "syscall" - "time" "github.com/sssmaran/WaylogCLI/examples/microdemo" "github.com/sssmaran/WaylogCLI/internal/config" - waylog "github.com/sssmaran/WaylogCLI/pkg" - wayloghttp "github.com/sssmaran/WaylogCLI/pkg/http" - kafkatransport "github.com/sssmaran/WaylogCLI/pkg/transport/kafka" + wayloghttp "github.com/sssmaran/WaylogCLI/pkg/waylog/http" ) -type coded interface { - Code() string -} - func main() { - cfg := waylog.Config{ - Service: "api-gateway", - Env: "dev", - Version: "0.1.0", - DeploymentID: os.Getenv("DEPLOYMENT_ID"), - ErrorClassifier: func(err error) string { - if err == nil { - return "" - } - if c, ok := err.(coded); ok { - return c.Code() - } - return "" - }, - } - - if brokers := config.SplitEnvList("KAFKA_BROKERS"); len(brokers) > 0 { - kt, err := kafkatransport.New(kafkatransport.Config{ - Brokers: brokers, - Topic: config.Getenv("KAFKA_TOPIC", "wide_events"), - }) - if err != nil { - slog.Error("kafka transport init failed", "err", err) - os.Exit(1) - } - cfg.Transport = kt - } - - if err := waylog.Init(cfg); err != nil { + if err := microdemo.InitService("api-gateway"); err != nil { slog.Error("waylog init failed", "err", err) os.Exit(1) } @@ -58,35 +20,8 @@ func main() { gateway := microdemo.NewGatewayHandler(checkoutURL) mux := http.NewServeMux() - mux.Handle("/purchase", wayloghttp.Middleware(http.HandlerFunc(gateway.ServePurchase))) + mux.Handle("/purchase", wayloghttp.HTTP(http.HandlerFunc(gateway.ServePurchase))) mux.HandleFunc("/demo", gateway.ServeDemo) - server := &http.Server{ - Addr: ":9081", - Handler: mux, - } - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - go func() { - slog.Info("api-gateway listening", "addr", ":9081") - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - slog.Error("api-gateway server error", "err", err) - os.Exit(1) - } - }() - - <-ctx.Done() - slog.Info("api-gateway shutdown signal received") - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := server.Shutdown(shutdownCtx); err != nil { - slog.Error("api-gateway graceful shutdown failed", "err", err) - } - if err := waylog.Shutdown(shutdownCtx); err != nil { - slog.Error("waylog shutdown failed", "err", err) - } - slog.Info("api-gateway shutdown complete") + microdemo.RunService("api-gateway", ":9081", mux) } diff --git a/examples/cmd/checkout-demo/main.go b/examples/cmd/checkout-demo/main.go index 4868a21..8b4e734 100644 --- a/examples/cmd/checkout-demo/main.go +++ b/examples/cmd/checkout-demo/main.go @@ -1,92 +1,26 @@ package main import ( - "context" "log/slog" "net/http" "os" - "os/signal" - "syscall" - "time" "github.com/sssmaran/WaylogCLI/examples/microdemo" "github.com/sssmaran/WaylogCLI/internal/config" - waylog "github.com/sssmaran/WaylogCLI/pkg" - wayloghttp "github.com/sssmaran/WaylogCLI/pkg/http" - kafkatransport "github.com/sssmaran/WaylogCLI/pkg/transport/kafka" + wayloghttp "github.com/sssmaran/WaylogCLI/pkg/waylog/http" ) -type coded interface { - Code() string -} - func main() { - cfg := waylog.Config{ - Service: "checkout", - Env: "dev", - Version: "0.1.0", - DeploymentID: os.Getenv("DEPLOYMENT_ID"), - ErrorClassifier: func(err error) string { - if err == nil { - return "" - } - if c, ok := err.(coded); ok { - return c.Code() - } - return "" - }, - } - - if brokers := config.SplitEnvList("KAFKA_BROKERS"); len(brokers) > 0 { - kt, err := kafkatransport.New(kafkatransport.Config{ - Brokers: brokers, - Topic: config.Getenv("KAFKA_TOPIC", "wide_events"), - }) - if err != nil { - slog.Error("kafka transport init failed", "err", err) - os.Exit(1) - } - cfg.Transport = kt - } - - if err := waylog.Init(cfg); err != nil { + if err := microdemo.InitService("checkout"); err != nil { slog.Error("waylog init failed", "err", err) os.Exit(1) } paymentURL := config.Getenv("PAYMENT_URL", "http://localhost:9083") dbURL := config.Getenv("DB_URL", "http://localhost:9084") - handler := microdemo.NewCheckoutHandler(paymentURL, dbURL) mux := http.NewServeMux() - mux.Handle("/checkout", wayloghttp.Middleware(handler)) + mux.Handle("/checkout", wayloghttp.HTTP(microdemo.NewCheckoutHandler(paymentURL, dbURL))) - server := &http.Server{ - Addr: ":9082", - Handler: mux, - } - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - go func() { - slog.Info("checkout-demo listening", "addr", ":9082") - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - slog.Error("checkout-demo server error", "err", err) - os.Exit(1) - } - }() - - <-ctx.Done() - slog.Info("checkout-demo shutdown signal received") - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := server.Shutdown(shutdownCtx); err != nil { - slog.Error("checkout-demo graceful shutdown failed", "err", err) - } - if err := waylog.Shutdown(shutdownCtx); err != nil { - slog.Error("waylog shutdown failed", "err", err) - } - slog.Info("checkout-demo shutdown complete") + microdemo.RunService("checkout-demo", ":9082", mux) } diff --git a/examples/cmd/db-demo/main.go b/examples/cmd/db-demo/main.go index 21ac227..3258a85 100644 --- a/examples/cmd/db-demo/main.go +++ b/examples/cmd/db-demo/main.go @@ -1,90 +1,22 @@ package main import ( - "context" "log/slog" "net/http" "os" - "os/signal" - "syscall" - "time" "github.com/sssmaran/WaylogCLI/examples/microdemo" - "github.com/sssmaran/WaylogCLI/internal/config" - waylog "github.com/sssmaran/WaylogCLI/pkg" - wayloghttp "github.com/sssmaran/WaylogCLI/pkg/http" - kafkatransport "github.com/sssmaran/WaylogCLI/pkg/transport/kafka" + wayloghttp "github.com/sssmaran/WaylogCLI/pkg/waylog/http" ) -type coded interface { - Code() string -} - func main() { - cfg := waylog.Config{ - Service: "db", - Env: "dev", - Version: "0.1.0", - DeploymentID: os.Getenv("DEPLOYMENT_ID"), - ErrorClassifier: func(err error) string { - if err == nil { - return "" - } - if c, ok := err.(coded); ok { - return c.Code() - } - return "" - }, - } - - if brokers := config.SplitEnvList("KAFKA_BROKERS"); len(brokers) > 0 { - kt, err := kafkatransport.New(kafkatransport.Config{ - Brokers: brokers, - Topic: config.Getenv("KAFKA_TOPIC", "wide_events"), - }) - if err != nil { - slog.Error("kafka transport init failed", "err", err) - os.Exit(1) - } - cfg.Transport = kt - } - - if err := waylog.Init(cfg); err != nil { + if err := microdemo.InitService("db"); err != nil { slog.Error("waylog init failed", "err", err) os.Exit(1) } - handler := microdemo.NewDBHandler() - mux := http.NewServeMux() - mux.Handle("/db", wayloghttp.Middleware(handler)) + mux.Handle("/db", wayloghttp.HTTP(microdemo.NewDBHandler())) - server := &http.Server{ - Addr: ":9084", - Handler: mux, - } - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - go func() { - slog.Info("db-demo listening", "addr", ":9084") - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - slog.Error("db-demo server error", "err", err) - os.Exit(1) - } - }() - - <-ctx.Done() - slog.Info("db-demo shutdown signal received") - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := server.Shutdown(shutdownCtx); err != nil { - slog.Error("db-demo graceful shutdown failed", "err", err) - } - if err := waylog.Shutdown(shutdownCtx); err != nil { - slog.Error("waylog shutdown failed", "err", err) - } - slog.Info("db-demo shutdown complete") + microdemo.RunService("db-demo", ":9084", mux) } diff --git a/examples/cmd/payment-demo/main.go b/examples/cmd/payment-demo/main.go index 4018e3e..1240f20 100644 --- a/examples/cmd/payment-demo/main.go +++ b/examples/cmd/payment-demo/main.go @@ -1,90 +1,22 @@ package main import ( - "context" "log/slog" "net/http" "os" - "os/signal" - "syscall" - "time" "github.com/sssmaran/WaylogCLI/examples/microdemo" - "github.com/sssmaran/WaylogCLI/internal/config" - waylog "github.com/sssmaran/WaylogCLI/pkg" - wayloghttp "github.com/sssmaran/WaylogCLI/pkg/http" - kafkatransport "github.com/sssmaran/WaylogCLI/pkg/transport/kafka" + wayloghttp "github.com/sssmaran/WaylogCLI/pkg/waylog/http" ) -type coded interface { - Code() string -} - func main() { - cfg := waylog.Config{ - Service: "payment", - Env: "dev", - Version: "0.1.0", - DeploymentID: os.Getenv("DEPLOYMENT_ID"), - ErrorClassifier: func(err error) string { - if err == nil { - return "" - } - if c, ok := err.(coded); ok { - return c.Code() - } - return "" - }, - } - - if brokers := config.SplitEnvList("KAFKA_BROKERS"); len(brokers) > 0 { - kt, err := kafkatransport.New(kafkatransport.Config{ - Brokers: brokers, - Topic: config.Getenv("KAFKA_TOPIC", "wide_events"), - }) - if err != nil { - slog.Error("kafka transport init failed", "err", err) - os.Exit(1) - } - cfg.Transport = kt - } - - if err := waylog.Init(cfg); err != nil { + if err := microdemo.InitService("payment"); err != nil { slog.Error("waylog init failed", "err", err) os.Exit(1) } - handler := microdemo.NewPaymentHandler() - mux := http.NewServeMux() - mux.Handle("/pay", wayloghttp.Middleware(handler)) + mux.Handle("/pay", wayloghttp.HTTP(microdemo.NewPaymentHandler())) - server := &http.Server{ - Addr: ":9083", - Handler: mux, - } - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - go func() { - slog.Info("payment-demo listening", "addr", ":9083") - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - slog.Error("payment-demo server error", "err", err) - os.Exit(1) - } - }() - - <-ctx.Done() - slog.Info("payment-demo shutdown signal received") - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := server.Shutdown(shutdownCtx); err != nil { - slog.Error("payment-demo graceful shutdown failed", "err", err) - } - if err := waylog.Shutdown(shutdownCtx); err != nil { - slog.Error("waylog shutdown failed", "err", err) - } - slog.Info("payment-demo shutdown complete") + microdemo.RunService("payment-demo", ":9083", mux) } diff --git a/examples/microdemo/checkout.go b/examples/microdemo/checkout.go index e14a917..8f3b706 100644 --- a/examples/microdemo/checkout.go +++ b/examples/microdemo/checkout.go @@ -1,13 +1,14 @@ package microdemo import ( + "bytes" + "context" "encoding/json" "io" "net/http" - waylog "github.com/sssmaran/WaylogCLI/pkg" - wayloghttp "github.com/sssmaran/WaylogCLI/pkg/http" - "github.com/sssmaran/WaylogCLI/pkg/trace" + wayloghttp "github.com/sssmaran/WaylogCLI/pkg/waylog/http" + waylogv2 "github.com/sssmaran/WaylogCLI/pkg/waylog/v2" ) type CheckoutHandler struct { @@ -22,108 +23,100 @@ func NewCheckoutHandler(paymentURL, dbURL string) *CheckoutHandler { paymentURL: paymentURL, dbURL: dbURL, paymentClient: &http.Client{ - Transport: wayloghttp.WrapTransport(http.DefaultTransport, "payment"), + Transport: wayloghttp.NewTransport(http.DefaultTransport, "payment"), }, dbClient: &http.Client{ - Transport: wayloghttp.WrapTransport(http.DefaultTransport, "db"), + Transport: wayloghttp.NewTransport(http.DefaultTransport, "db"), }, } } func (h *CheckoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - force := r.URL.Query().Get("force") - - traceID := "" - if tc, ok := trace.FromContext(ctx); ok { - traceID = tc.TraceID - } - - ctx = waylog.WithUser(ctx, waylog.User{ - ID: "demo-user", - Tier: "standard", - Region: "us-east-1", - }) - ctx = waylog.WithFlow(ctx, "checkout") - - if force == "checkout_fail" { - waylog.Error(ctx, codedError{code: "CHK_500", message: "checkout processing failed"}) - - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]any{ - "success": false, - "trace_id": traceID, - "error": "checkout processing failed", - }) + reqBody, err := parsePurchaseRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } + setDemoFields(ctx, "checkout", reqBody) - dbReq, err := http.NewRequestWithContext(ctx, "GET", h.dbURL+"/db?force="+force, nil) - if err != nil { - w.WriteHeader(http.StatusBadGateway) - json.NewEncoder(w).Encode(map[string]any{ - "success": false, - "trace_id": traceID, - "error": err.Error(), - }) + if reqBody.Scenario == ScenarioSuppressedPayment502 { + h.serveSuppressedPayment(w, reqBody, ctx) return } - dbResp, err := h.dbClient.Do(dbReq) - if err != nil { - waylog.Error(ctx, codedError{code: "CHK_DB_502", message: "db service unavailable"}) + if err := h.loadCart(ctx, reqBody); err != nil { w.WriteHeader(http.StatusBadGateway) - json.NewEncoder(w).Encode(map[string]any{ - "success": false, - "trace_id": traceID, - "error": "db service unavailable", - }) + _ = json.NewEncoder(w).Encode(response(ctx, false, reqBody, "database operation failed")) return } - _, _ = io.Copy(io.Discard, dbResp.Body) - _ = dbResp.Body.Close() - - if dbResp.StatusCode >= http.StatusInternalServerError { - waylog.Error(ctx, codedError{code: "CHK_DB_DOWNSTREAM", message: "downstream db failed"}) + if err := h.chargePayment(ctx, reqBody); err != nil { w.WriteHeader(http.StatusBadGateway) - json.NewEncoder(w).Encode(map[string]any{ - "success": false, - "trace_id": traceID, - "error": "database operation failed", - }) + _ = json.NewEncoder(w).Encode(response(ctx, false, reqBody, "payment gateway failure")) return } - req, err := http.NewRequestWithContext(ctx, "GET", h.paymentURL+"/pay?force="+force, nil) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response(ctx, true, reqBody, "")) +} + +func (h *CheckoutHandler) serveSuppressedPayment(w http.ResponseWriter, reqBody PurchaseRequest, ctx context.Context) { + _ = h.loadCart(ctx, reqBody) + _, _ = h.callPayment(ctx, reqBody) + w.WriteHeader(http.StatusBadGateway) + waylogv2.Suppress(ctx) + _ = json.NewEncoder(w).Encode(response(ctx, false, reqBody, "known payment gateway issue suppressed")) +} + +func (h *CheckoutHandler) loadCart(ctx context.Context, reqBody PurchaseRequest) error { + return waylogv2.StepVoid(ctx, "db.load_cart", func(ctx context.Context) error { + raw, _ := json.Marshal(reqBody) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.dbURL+"/db", bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := h.dbClient.Do(req) + if err != nil { + return waylogv2.NewError("DB_503", waylogv2.WithReason("db service unavailable")) + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + if resp.StatusCode >= http.StatusInternalServerError { + return waylogv2.NewError("DB_503", waylogv2.WithReason("database unavailable")) + } + return nil + }) +} + +func (h *CheckoutHandler) chargePayment(ctx context.Context, reqBody PurchaseRequest) error { + return waylogv2.StepVoid(ctx, "payment.charge", func(ctx context.Context) error { + resp, err := h.callPayment(ctx, reqBody) + if err != nil { + return err + } + if resp >= http.StatusInternalServerError { + werr := waylogv2.NewError("PMT_502", waylogv2.WithReason("upstream gateway 5xx")) + waylogv2.From(ctx).Warn("retrying payment", waylogv2.F{"attempt": 1, "max_attempts": 2}) + waylogv2.From(ctx).Error("upstream gateway 5xx", werr, waylogv2.F{"status": resp}) + return werr + } + return nil + }) +} + +func (h *CheckoutHandler) callPayment(ctx context.Context, reqBody PurchaseRequest) (int, error) { + raw, _ := json.Marshal(reqBody) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.paymentURL+"/pay", bytes.NewReader(raw)) if err != nil { - w.WriteHeader(http.StatusBadGateway) - json.NewEncoder(w).Encode(map[string]any{ - "success": false, - "trace_id": traceID, - "error": err.Error(), - }) - return + return 0, err } - + req.Header.Set("Content-Type", "application/json") resp, err := h.paymentClient.Do(req) if err != nil { - waylog.Error(ctx, codedError{code: "CHK_502", message: "payment service unavailable"}) - w.WriteHeader(http.StatusBadGateway) - json.NewEncoder(w).Encode(map[string]any{ - "success": false, - "trace_id": traceID, - "error": "payment service unavailable", - }) - return + return 0, waylogv2.NewError("PMT_502", waylogv2.WithReason("payment service unavailable")) } defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - - if resp.StatusCode >= http.StatusInternalServerError { - waylog.Error(ctx, codedError{code: "CHK_DOWNSTREAM", message: "downstream payment failed"}) - } - - w.WriteHeader(resp.StatusCode) - w.Write(body) + _, _ = io.Copy(io.Discard, resp.Body) + return resp.StatusCode, nil } diff --git a/examples/microdemo/db.go b/examples/microdemo/db.go index 9abb0f6..d630ca8 100644 --- a/examples/microdemo/db.go +++ b/examples/microdemo/db.go @@ -1,11 +1,11 @@ package microdemo import ( + "context" "encoding/json" "net/http" - waylog "github.com/sssmaran/WaylogCLI/pkg" - "github.com/sssmaran/WaylogCLI/pkg/trace" + waylogv2 "github.com/sssmaran/WaylogCLI/pkg/waylog/v2" ) type DBHandler struct{} @@ -16,33 +16,31 @@ func NewDBHandler() *DBHandler { func (h *DBHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - ctx = waylog.WithUser(ctx, waylog.User{ - ID: "demo-user", - Tier: "standard", - Region: "us-east-1", - }) - ctx = waylog.WithFlow(ctx, "db") + reqBody, err := parsePurchaseRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + setDemoFields(ctx, "db", reqBody) - traceID := "" - if tc, ok := trace.FromContext(ctx); ok { - traceID = tc.TraceID + if reqBody.Scenario == ScenarioSuppressedPayment502 { + w.WriteHeader(http.StatusOK) + waylogv2.Suppress(ctx) + _ = json.NewEncoder(w).Encode(response(ctx, true, reqBody, "")) + return } - if r.URL.Query().Get("force") == "db_fail" { - waylog.Error(ctx, codedError{code: "DB_503", message: "database unavailable"}) + if err := loadCart(ctx); err != nil { w.WriteHeader(http.StatusServiceUnavailable) - json.NewEncoder(w).Encode(map[string]any{ - "success": false, - "trace_id": traceID, - "error": "database unavailable", - }) + _ = json.NewEncoder(w).Encode(response(ctx, false, reqBody, "database unavailable")) return } - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]any{ - "success": true, - "trace_id": traceID, - "result": "db_ok", + _ = json.NewEncoder(w).Encode(response(ctx, true, reqBody, "")) +} + +func loadCart(ctx context.Context) error { + return waylogv2.StepVoid(ctx, "cart.lookup", func(ctx context.Context) error { + return nil }) } diff --git a/examples/microdemo/gateway.go b/examples/microdemo/gateway.go index 4d763f1..db7706d 100644 --- a/examples/microdemo/gateway.go +++ b/examples/microdemo/gateway.go @@ -1,14 +1,23 @@ package microdemo import ( + "bytes" + "context" _ "embed" "encoding/json" "io" "net/http" - waylog "github.com/sssmaran/WaylogCLI/pkg" - wayloghttp "github.com/sssmaran/WaylogCLI/pkg/http" - "github.com/sssmaran/WaylogCLI/pkg/trace" + wayloghttp "github.com/sssmaran/WaylogCLI/pkg/waylog/http" + waylogv2 "github.com/sssmaran/WaylogCLI/pkg/waylog/v2" +) + +const ( + ScenarioHappy = "happy" + ScenarioPayment502 = "payment_502" + ScenarioSuppressedPayment502 = "suppressed_payment_502" + + demoUserID = "demo-user" ) //go:embed ui.html @@ -17,53 +26,56 @@ var uiHTML []byte type GatewayHandler struct { checkoutURL string client *http.Client - uiHTML []byte +} + +type PurchaseRequest struct { + SKU string `json:"sku"` + Scenario string `json:"scenario"` } func NewGatewayHandler(checkoutURL string) *GatewayHandler { return &GatewayHandler{ checkoutURL: checkoutURL, client: &http.Client{ - Transport: wayloghttp.WrapTransport(http.DefaultTransport, "checkout"), + Transport: wayloghttp.NewTransport(http.DefaultTransport, "checkout"), }, - uiHTML: uiHTML, } } func (h *GatewayHandler) ServeDemo(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - w.Write(h.uiHTML) + _, _ = w.Write(uiHTML) } func (h *GatewayHandler) ServePurchase(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - force := r.URL.Query().Get("force") - - req, err := http.NewRequestWithContext(ctx, "GET", h.checkoutURL+"/checkout?force="+force, nil) + reqBody, err := parsePurchaseRequest(r) if err != nil { - http.Error(w, "failed to create request", http.StatusInternalServerError) + http.Error(w, err.Error(), http.StatusBadRequest) return } + setDemoFields(ctx, "api-gateway", reqBody) - resp, err := h.client.Do(req) + raw, _ := json.Marshal(reqBody) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.checkoutURL+"/checkout", bytes.NewReader(raw)) if err != nil { - ctx = waylog.WithUser(ctx, waylog.User{ - ID: "demo-user", - Tier: "standard", - Region: "us-east-1", - }) - ctx = waylog.WithFlow(ctx, "purchase") - waylog.Error(ctx, codedError{code: "GW_502", message: "checkout service unavailable"}) - - traceID := "" - if tc, ok := trace.FromContext(ctx); ok { - traceID = tc.TraceID - } + http.Error(w, "failed to create checkout request", http.StatusInternalServerError) + return + } + req.Header.Set("Content-Type", "application/json") + var resp *http.Response + err = waylogv2.StepVoid(ctx, "checkout.purchase", func(ctx context.Context) error { + var doErr error + resp, doErr = h.client.Do(req.WithContext(ctx)) + return doErr + }) + if err != nil { w.WriteHeader(http.StatusBadGateway) - json.NewEncoder(w).Encode(map[string]any{ + _ = json.NewEncoder(w).Encode(map[string]any{ "success": false, - "trace_id": traceID, + "trace_id": waylogv2.TraceID(ctx), + "scenario": reqBody.Scenario, "error": "checkout service unavailable", }) return @@ -75,18 +87,31 @@ func (h *GatewayHandler) ServePurchase(w http.ResponseWriter, r *http.Request) { http.Error(w, "failed to read checkout response", http.StatusInternalServerError) return } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(body) +} - ctx = waylog.WithUser(ctx, waylog.User{ - ID: "demo-user", - Tier: "standard", - Region: "us-east-1", - }) - ctx = waylog.WithFlow(ctx, "purchase") - - if resp.StatusCode >= http.StatusInternalServerError { - waylog.Error(ctx, codedError{code: "GW_DOWNSTREAM", message: "downstream checkout failed"}) +func parsePurchaseRequest(r *http.Request) (PurchaseRequest, error) { + req := PurchaseRequest{SKU: "X1", Scenario: ScenarioHappy} + if r.Method == http.MethodPost && r.Body != nil { + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { + return PurchaseRequest{}, err + } } - - w.WriteHeader(resp.StatusCode) - w.Write(body) + if scenario := r.URL.Query().Get("scenario"); scenario != "" { + req.Scenario = scenario + } + if force := r.URL.Query().Get("force"); force != "" { + req.Scenario = legacyForceScenario(force) + } + if req.SKU == "" { + req.SKU = "X1" + } + req.Scenario = normalizeScenario(req.Scenario) + if req.Scenario == "" { + return PurchaseRequest{}, errUnknownScenario + } + return req, nil } diff --git a/examples/microdemo/microdemo_test.go b/examples/microdemo/microdemo_test.go index 4d45702..31e30ad 100644 --- a/examples/microdemo/microdemo_test.go +++ b/examples/microdemo/microdemo_test.go @@ -1,142 +1,243 @@ package microdemo_test import ( + "bytes" "context" "encoding/json" - "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" "github.com/sssmaran/WaylogCLI/examples/microdemo" - waylog "github.com/sssmaran/WaylogCLI/pkg" - wayloghttp "github.com/sssmaran/WaylogCLI/pkg/http" - "github.com/sssmaran/WaylogCLI/pkg/transport" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" + wayloghttp "github.com/sssmaran/WaylogCLI/pkg/waylog/http" + waylogv2 "github.com/sssmaran/WaylogCLI/pkg/waylog/v2" ) -func TestMicroDemoChain(t *testing.T) { - mem := transport.NewInMemoryTransport() - - // Create a fresh client for each test to avoid the global Init singleton. - client, err := waylog.New(waylog.Config{ - Service: "test-gateway", - Env: "test", - Version: "0.0.1", - Transport: mem, - ErrorClassifier: func(err error) string { - if err == nil { - return "" - } - type coded interface{ Code() string } - if c, ok := err.(coded); ok { - return c.Code() - } - return "" - }, +func TestCheckoutPayment502EmitsNarrativeEvent(t *testing.T) { + out := initSDK(t, "checkout") + db := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"success":true}`)) + })) + defer db.Close() + payment := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`{"success":false}`)) + })) + defer payment.Close() + + handler := wayloghttp.HTTP(microdemo.NewCheckoutHandler(payment.URL, db.URL)) + resp := postPurchase(t, handler, "/checkout", microdemo.ScenarioPayment502) + if resp.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", resp.Code, http.StatusBadGateway) + } + + ev := oneEvent(t, out) + if ev.SchemaVersion != eventv2.SchemaVersion2 { + t.Fatalf("schema_version = %q, want %q", ev.SchemaVersion, eventv2.SchemaVersion2) + } + if ev.Service != "checkout" || ev.Status != eventv2.StatusError { + t.Fatalf("service/status = %s/%s, want checkout/error", ev.Service, ev.Status) + } + if ev.Anchor == nil || ev.Anchor.Step != "payment.charge" || ev.Anchor.ErrorCode != "PMT_502" { + t.Fatalf("anchor = %#v, want payment.charge/PMT_502", ev.Anchor) + } + requireStep(t, ev, "db.load_cart", eventv2.StepStatusOK, "") + requireStep(t, ev, "payment.charge", eventv2.StepStatusError, "payment") + requireLog(t, ev, eventv2.LogLevelWarn, "retrying payment") + requireLog(t, ev, eventv2.LogLevelError, "upstream gateway 5xx") + requireField(t, ev, "user", "id", "demo-user") + requireField(t, ev, "demo", "scenario", microdemo.ScenarioPayment502) + requireField(t, ev, "http", "route", "/checkout") +} + +func TestCheckoutHappyEmitsOKWithoutAnchor(t *testing.T) { + out := initSDK(t, "checkout") + db := httptest.NewServer(okJSON()) + defer db.Close() + payment := httptest.NewServer(okJSON()) + defer payment.Close() + + resp := postPurchase(t, wayloghttp.HTTP(microdemo.NewCheckoutHandler(payment.URL, db.URL)), "/checkout", microdemo.ScenarioHappy) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK) + } + ev := oneEvent(t, out) + if ev.Status != eventv2.StatusOK { + t.Fatalf("status = %s, want ok", ev.Status) + } + if ev.Anchor != nil { + t.Fatalf("anchor = %#v, want nil", ev.Anchor) + } + requireStep(t, ev, "db.load_cart", eventv2.StepStatusOK, "db") + requireStep(t, ev, "payment.charge", eventv2.StepStatusOK, "payment") + requireNoLog(t, ev, eventv2.LogLevelWarn, "retrying payment") +} + +func TestPaymentSuppressed502IsHeaderOnly(t *testing.T) { + out := initSDK(t, "payment") + resp := postPurchase(t, wayloghttp.HTTP(microdemo.NewPaymentHandler()), "/pay", microdemo.ScenarioSuppressedPayment502) + if resp.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", resp.Code, http.StatusBadGateway) + } + ev := oneEvent(t, out) + if ev.Status != eventv2.StatusSuppressed { + t.Fatalf("status = %s, want suppressed", ev.Status) + } + if ev.Anchor != nil || len(ev.Steps) != 0 || len(ev.Logs) != 0 { + t.Fatalf("suppressed event should be header-only: anchor=%#v steps=%d logs=%d", ev.Anchor, len(ev.Steps), len(ev.Logs)) + } +} + +func TestPayment502DoesNotStealCheckoutNarrativeAnchor(t *testing.T) { + out := initSDK(t, "payment") + resp := postPurchase(t, wayloghttp.HTTP(microdemo.NewPaymentHandler()), "/pay", microdemo.ScenarioPayment502) + if resp.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", resp.Code, http.StatusBadGateway) + } + ev := oneEvent(t, out) + if ev.Status != eventv2.StatusOK { + t.Fatalf("status = %s, want ok so checkout remains the trace failure anchor", ev.Status) + } + if ev.Anchor != nil { + t.Fatalf("anchor = %#v, want nil", ev.Anchor) + } + requireStep(t, ev, "acquirer.charge", eventv2.StepStatusOK, "") +} + +func TestGatewayDemoUIAndCheckoutPropagationStep(t *testing.T) { + out := initSDK(t, "api-gateway") + checkout := httptest.NewServer(okJSON()) + defer checkout.Close() + gateway := microdemo.NewGatewayHandler(checkout.URL) + + mux := http.NewServeMux() + mux.Handle("/purchase", wayloghttp.HTTP(http.HandlerFunc(gateway.ServePurchase))) + mux.HandleFunc("/demo", gateway.ServeDemo) + + uiReq := httptest.NewRequest(http.MethodGet, "/demo", nil) + uiResp := httptest.NewRecorder() + mux.ServeHTTP(uiResp, uiReq) + if uiResp.Code != http.StatusOK || !strings.Contains(uiResp.Body.String(), "Payment gateway 502") { + t.Fatalf("demo UI status/body = %d/%q", uiResp.Code, uiResp.Body.String()) + } + + resp := postPurchase(t, mux, "/purchase", microdemo.ScenarioHappy) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK) + } + ev := oneEvent(t, out) + requireStep(t, ev, "checkout.purchase", eventv2.StepStatusOK, "checkout") +} + +func initSDK(t *testing.T, service string) *bytes.Buffer { + t.Helper() + shutdownSDK(t) + var out bytes.Buffer + if err := waylogv2.Init(waylogv2.Config{ + Service: service, + Env: "test", + Version: "test", + Output: &out, + }); err != nil { + t.Fatalf("waylog init: %v", err) + } + t.Cleanup(func() { + shutdownSDK(t) }) - if err != nil { - t.Fatalf("waylog.New: %v", err) - } - defer client.Close(context.Background()) - - // Start payment test server - paymentHandler := microdemo.NewPaymentHandler() - paymentServer := httptest.NewServer(wayloghttp.MiddlewareWithClient(client)(paymentHandler)) - defer paymentServer.Close() - - // Start db test server - dbHandler := microdemo.NewDBHandler() - dbServer := httptest.NewServer(wayloghttp.MiddlewareWithClient(client)(dbHandler)) - defer dbServer.Close() - - // Start checkout test server pointing at db, then payment - checkoutHandler := microdemo.NewCheckoutHandler(paymentServer.URL, dbServer.URL) - checkoutServer := httptest.NewServer(wayloghttp.MiddlewareWithClient(client)(checkoutHandler)) - defer checkoutServer.Close() - - // Start gateway test server pointing at checkout - gatewayHandler := microdemo.NewGatewayHandler(checkoutServer.URL) - gatewayMux := http.NewServeMux() - gatewayMux.Handle("/purchase", wayloghttp.MiddlewareWithClient(client)(http.HandlerFunc(gatewayHandler.ServePurchase))) - gatewayMux.HandleFunc("/demo", gatewayHandler.ServeDemo) - gatewayServer := httptest.NewServer(gatewayMux) - defer gatewayServer.Close() - - tests := []struct { - name string - force string - wantStatus int - wantOK bool - }{ - {"success", "", http.StatusOK, true}, - {"payment_fail", "payment_fail", http.StatusBadGateway, false}, - {"checkout_fail", "checkout_fail", http.StatusInternalServerError, false}, - {"db_fail", "db_fail", http.StatusBadGateway, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - url := gatewayServer.URL + "/purchase" - if tt.force != "" { - url += "?force=" + tt.force - } + return &out +} - resp, err := http.Get(url) - if err != nil { - t.Fatalf("GET %s: %v", url, err) - } - defer resp.Body.Close() +func shutdownSDK(t *testing.T) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := waylogv2.Shutdown(ctx); err != nil { + t.Fatalf("waylog shutdown: %v", err) + } +} - if resp.StatusCode != tt.wantStatus { - t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus) - } +func postPurchase(t *testing.T, handler http.Handler, path, scenario string) *httptest.ResponseRecorder { + t.Helper() + body := strings.NewReader(`{"sku":"X1","scenario":"` + scenario + `"}`) + req := httptest.NewRequest(http.MethodPost, path, body) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + handler.ServeHTTP(resp, req) + return resp +} - var body map[string]any - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - t.Fatalf("decode body: %v", err) - } +func oneEvent(t *testing.T, out *bytes.Buffer) eventv2.Event { + t.Helper() + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 1 || strings.TrimSpace(lines[0]) == "" { + t.Fatalf("captured %d events, want 1: %q", len(lines), out.String()) + } + var ev eventv2.Event + if err := json.Unmarshal([]byte(lines[0]), &ev); err != nil { + t.Fatalf("unmarshal event: %v\n%s", err, lines[0]) + } + return ev +} - success, _ := body["success"].(bool) - if success != tt.wantOK { - t.Errorf("success = %v, want %v", success, tt.wantOK) +func requireStep(t *testing.T, ev eventv2.Event, name, status, downstream string) { + t.Helper() + for _, step := range ev.Steps { + if step.Name != name { + continue + } + if step.Status != status { + t.Fatalf("step %s status = %s, want %s", name, step.Status, status) + } + if downstream != "" { + if step.Downstream == nil || step.Downstream.Service != downstream { + t.Fatalf("step %s downstream = %#v, want service %q", name, step.Downstream, downstream) } - - traceID, _ := body["trace_id"].(string) - if traceID == "" { - t.Error("trace_id should not be empty") + if step.SpanID == "" { + t.Fatalf("step %s span_id is empty; outbound transport did not record linkage", name) } - }) + } + return } + t.Fatalf("missing step %q in %#v", name, ev.Steps) +} - // Test UI endpoint - t.Run("demo_ui", func(t *testing.T) { - resp, err := http.Get(gatewayServer.URL + "/demo") - if err != nil { - t.Fatalf("GET /demo: %v", err) +func requireLog(t *testing.T, ev eventv2.Event, level, msg string) { + t.Helper() + for _, log := range ev.Logs { + if log.Level == level && log.Msg == msg { + return } - defer resp.Body.Close() + } + t.Fatalf("missing log %s/%q in %#v", level, msg, ev.Logs) +} - if resp.StatusCode != http.StatusOK { - t.Errorf("status = %d, want 200", resp.StatusCode) - } - ct := resp.Header.Get("Content-Type") - if ct != "text/html" { - t.Errorf("Content-Type = %q, want text/html", ct) +func requireNoLog(t *testing.T, ev eventv2.Event, level, msg string) { + t.Helper() + for _, log := range ev.Logs { + if log.Level == level && log.Msg == msg { + t.Fatalf("unexpected log %s/%q in %#v", level, msg, ev.Logs) } - }) + } +} - // Allow time for events to flush - time.Sleep(2 * time.Second) - events := mem.Events() - if len(events) == 0 { - t.Log("no events captured (expected with shared client across services)") - } else { - t.Logf("captured %d events", len(events)) - for i, ev := range events { - t.Logf("event[%d]: %s trace=%s", i, ev.EventName, ev.Request.TraceID) - } +func requireField(t *testing.T, ev eventv2.Event, top, key string, want any) { + t.Helper() + fields, _ := ev.Fields[top].(map[string]any) + if fields == nil { + t.Fatalf("missing fields.%s in %#v", top, ev.Fields) } + if got := fields[key]; got != want { + t.Fatalf("fields.%s.%s = %#v, want %#v", top, key, got, want) + } +} - fmt.Printf("Integration test passed: %d scenarios verified\n", len(tests)+1) +func okJSON() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"success":true}`)) + } } diff --git a/examples/microdemo/payment.go b/examples/microdemo/payment.go index f5cf55d..bf093fe 100644 --- a/examples/microdemo/payment.go +++ b/examples/microdemo/payment.go @@ -1,12 +1,11 @@ package microdemo import ( + "context" "encoding/json" "net/http" - "strconv" - waylog "github.com/sssmaran/WaylogCLI/pkg" - "github.com/sssmaran/WaylogCLI/pkg/trace" + waylogv2 "github.com/sssmaran/WaylogCLI/pkg/waylog/v2" ) type PaymentHandler struct{} @@ -17,66 +16,36 @@ func NewPaymentHandler() *PaymentHandler { func (h *PaymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - - ctx = waylog.WithUser(ctx, waylog.User{ - ID: "demo-user", - Tier: "standard", - Region: "us-east-1", - }) - ctx = waylog.WithFlow(ctx, "payment") - ctx = waylog.WithMetadataKey(ctx, "tenant_id", "acme-corp") - ctx = waylog.WithMetadataKey(ctx, "cart_total_cents", 9999) - - force := r.URL.Query().Get("force") - success := true - statusCode := http.StatusOK - attempt := 1 - - switch force { - case "payment_fail": - success = false - statusCode = http.StatusBadGateway - ctx = waylog.WithErrorReason(ctx, "upstream acquirer returned 502; no retry configured for this route") - ctx = waylog.WithErrorPath(ctx, "https://runbooks.example.com/payments-502") - waylog.Error(ctx, codedError{code: "PMT_502", message: "payment gateway failure"}) - case "payment_retry": - if n, err := strconv.Atoi(r.URL.Query().Get("attempt")); err == nil && n > 0 { - attempt = n - } - ctx = waylog.WithAttempt(ctx, attempt) - if attempt > 1 { - ctx = waylog.WithRetry(ctx, waylog.Retry{Of: 3, PreviousAttemptID: r.URL.Query().Get("prev_attempt_id")}) - } - if attempt < 3 { - success = false - statusCode = http.StatusBadGateway - ctx = waylog.WithErrorReason(ctx, "upstream acquirer returned 502; retry policy permits up to 3 attempts") - ctx = waylog.WithErrorPath(ctx, "https://runbooks.example.com/payments-502") - waylog.Error(ctx, codedError{code: "PMT_502", message: "payment gateway failure"}) - } + reqBody, err := parsePurchaseRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } - - w.WriteHeader(statusCode) - - traceID, spanID := "", "" - if tc, ok := trace.FromContext(ctx); ok { - traceID = tc.TraceID - spanID = tc.SpanID + setDemoFields(ctx, "payment", reqBody) + + switch reqBody.Scenario { + case ScenarioHappy: + chargeAcquirer(ctx, false) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response(ctx, true, reqBody, "")) + case ScenarioPayment502: + chargeAcquirer(ctx, true) + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(response(ctx, false, reqBody, "upstream gateway 5xx")) + case ScenarioSuppressedPayment502: + w.WriteHeader(http.StatusBadGateway) + waylogv2.Suppress(ctx) + _ = json.NewEncoder(w).Encode(response(ctx, false, reqBody, "known payment gateway issue suppressed")) + default: + http.Error(w, errUnknownScenario.Error(), http.StatusBadRequest) } - - json.NewEncoder(w).Encode(map[string]any{ - "success": success, - "trace_id": traceID, - "span_id": spanID, - "attempt": attempt, - "amount": 99.99, - }) } -type codedError struct { - code string - message string +func chargeAcquirer(ctx context.Context, gatewayFailure bool) { + _ = waylogv2.StepVoid(ctx, "acquirer.charge", func(ctx context.Context) error { + if gatewayFailure { + waylogv2.From(ctx).Warn("acquirer returned gateway 5xx", waylogv2.F{"code": "PMT_502"}) + } + return nil + }) } - -func (e codedError) Error() string { return e.message } -func (e codedError) Code() string { return e.code } diff --git a/examples/microdemo/scenario.go b/examples/microdemo/scenario.go new file mode 100644 index 0000000..f3d7a50 --- /dev/null +++ b/examples/microdemo/scenario.go @@ -0,0 +1,60 @@ +package microdemo + +import ( + "context" + "errors" + + waylogv2 "github.com/sssmaran/WaylogCLI/pkg/waylog/v2" +) + +var errUnknownScenario = errors.New("unknown demo scenario") + +func normalizeScenario(s string) string { + switch s { + case "", ScenarioHappy: + return ScenarioHappy + case ScenarioPayment502: + return ScenarioPayment502 + case ScenarioSuppressedPayment502: + return ScenarioSuppressedPayment502 + default: + return "" + } +} + +func legacyForceScenario(force string) string { + switch force { + case "", "success": + return ScenarioHappy + case "payment_fail", "payment_retry": + return ScenarioPayment502 + default: + return "" + } +} + +func setDemoFields(ctx context.Context, service string, req PurchaseRequest) { + waylogv2.SetField(ctx, "user", map[string]any{ + "id": demoUserID, + "tier": "standard", + "region": "us-east-1", + }) + waylogv2.SetField(ctx, "demo", map[string]any{ + "scenario": req.Scenario, + "sku": req.SKU, + "service": service, + }) +} + +func response(ctx context.Context, success bool, req PurchaseRequest, errMsg string) map[string]any { + out := map[string]any{ + "success": success, + "trace_id": waylogv2.TraceID(ctx), + "scenario": req.Scenario, + "sku": req.SKU, + } + if errMsg != "" { + out["error"] = errMsg + } + return out +} diff --git a/examples/microdemo/service.go b/examples/microdemo/service.go new file mode 100644 index 0000000..b18efe2 --- /dev/null +++ b/examples/microdemo/service.go @@ -0,0 +1,49 @@ +package microdemo + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/sssmaran/WaylogCLI/internal/config" + waylogv2 "github.com/sssmaran/WaylogCLI/pkg/waylog/v2" +) + +func InitService(service string) error { + return waylogv2.Init(waylogv2.Config{ + Service: service, + Env: "demo", + Version: "0.1.0", + IngestURL: config.Getenv("INGEST_URL", "http://localhost:8080"), + APIKey: config.Getenv("WAYLOG_WRITE_KEY", ""), + DevMode: config.GetenvBool("WAYLOG_DEV", false), + }) +} + +func RunService(name, addr string, handler http.Handler) { + server := &http.Server{Addr: addr, Handler: handler} + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + go func() { + slog.Info(name+" listening", "addr", addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error(name+" server error", "err", err) + os.Exit(1) + } + }() + + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + slog.Error(name+" graceful shutdown failed", "err", err) + } + if err := waylogv2.Shutdown(shutdownCtx); err != nil { + slog.Error("waylog shutdown failed", "err", err) + } +} diff --git a/examples/microdemo/ui.html b/examples/microdemo/ui.html index 976754f..aeb2908 100644 --- a/examples/microdemo/ui.html +++ b/examples/microdemo/ui.html @@ -2,32 +2,221 @@ - - Waylog Micro-Demo + + + Waylog v2 Incident Demo -

Waylog Micro-Demo

-
- - - - -
-
Click a button to send a request...
+ +
+

Trigger a real checkout incident.

+

+ This demo sends schema 2.0 events through the Go v2 SDK across + api-gateway → checkout → db/payment. Use the failure scenario, then run + waylog errors, waylog explain, and + waylog blast to investigate it. +

+ +
+

Scenarios

+
+ + + +
+ +
+

Latest response

+
Choose a scenario…
+
+ +

+ Tip: after triggering payment_502, copy the returned + trace_id into waylog explain <trace_id>. +

+
+
+ diff --git a/examples/microdemo/ui_test.go b/examples/microdemo/ui_test.go new file mode 100644 index 0000000..2607b0e --- /dev/null +++ b/examples/microdemo/ui_test.go @@ -0,0 +1,57 @@ +package microdemo_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/sssmaran/WaylogCLI/examples/microdemo" +) + +func TestDemoUIProductShowcaseCopy(t *testing.T) { + gateway := microdemo.NewGatewayHandler("http://checkout.example") + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/demo", nil) + gateway.ServeDemo(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d", rec.Code) + } + html := rec.Body.String() + required := []string{ + "Simulate a checkout outage. Watch Waylog explain the cascade.", + "Run payment outage", + "Run happy checkout", + "Run suppressed known issue", + "Run cart not found", + "Run checkout 500", + "Run traffic burst", + "Production-like traffic mix", + "Burst captured", + "Open dashboard", + "Explain this trace", + "View impact", + "Still propagating through Waylog…", + "Happy checkout captured", + "Payment outage captured", + "Cart not found captured", + "Checkout 500 captured", + "Suppressed issue captured", + } + for _, needle := range required { + if !strings.Contains(html, needle) { + t.Fatalf("demo UI missing %q", needle) + } + } + forbidden := []string{ + "copy the returned", + "waylog explain <trace_id>", + "waylog errors", + "waylog blast", + } + for _, needle := range forbidden { + if strings.Contains(html, needle) { + t.Fatalf("demo UI still has terminal-first copy %q", needle) + } + } +} diff --git a/go.work.sum b/go.work.sum index b3c5df5..b5458b3 100644 --- a/go.work.sum +++ b/go.work.sum @@ -4,8 +4,12 @@ github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8V github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -20,12 +24,14 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -40,6 +46,7 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRW github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -47,9 +54,11 @@ github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyC github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/dashboard/static/chart.umd.min.js b/internal/dashboard/static/chart.umd.min.js deleted file mode 100644 index 0bae5b8..0000000 --- a/internal/dashboard/static/chart.umd.min.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Skipped minification because the original files appears to be already minified. - * Original file: /npm/chart.js@4.4.7/dist/chart.umd.js - * - * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files - */ -/*! - * Chart.js v4.4.7 - * https://www.chartjs.org - * (c) 2024 Chart.js Contributors - * Released under the MIT License - */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Go},get Decimation(){return Qo},get Filler(){return ma},get Legend(){return ya},get SubTitle(){return ka},get Title(){return Ma},get Tooltip(){return Ba}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)t-e)).pop(),e}function N(t){return!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return nt[s][e]et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const s=e.length;let n=0,o=s;if(t._sorted){const{iScale:a,_parsed:r}=t,l=a.axis,{min:h,max:c,minDefined:d,maxDefined:u}=a.getUserBounds();d&&(n=J(Math.min(it(r,l,h).lo,i?s:it(e,l,a.getPixelForValue(h)).lo),0,s-1)),o=u?J(Math.max(it(r,a.axis,c,!0).hi+1,i?0:it(e,l,a.getPixelForValue(c),!0).hi+1),n,s)-n:s-n}return{start:n,count:o}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class bt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var xt=new bt; -/*! - * @kurkle/color v0.3.2 - * https://github.com/kurkle/color#readme - * (c) 2023 Jukka Kurkela - * Released under the MIT License - */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Zt{constructor(t){if(t instanceof Zt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Zt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Jt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Jt(t)?t:new Zt(t)}function te(t){return Jt(t)?t:new Zt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function be(t,e){return me(t).getPropertyValue(e)}const xe=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=xe[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=t&&ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Math.floor(t.height*s),o=Math.floor(t.width*s);t.height=Math.floor(t.height),t.width=Math.floor(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};fe()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch(t){}return t}();function Pe(t,e){const i=be(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Je(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Je(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Je(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e)||"constructor"===e)return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Ze(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Je(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Ze(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const bi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,xi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(bi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(xi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:Z,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hx||l(n,b,p)&&0!==r(n,b),v=()=>!x||0===r(o,p)||l(o,b,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==b&&(x=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,b=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r{t[a]&&t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Xi={evaluateInteractionItems:Hi,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tji(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Yi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>Ui(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>Ui(t,ve(e,t),"y",i.intersect,s)}};const qi=["left","top","right","bottom"];function Ki(t,e){return t.filter((t=>t.pos===e))}function Gi(t,e){return t.filter((t=>-1===qi.indexOf(t.pos)&&t.box.axis===e))}function Zi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function Ji(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!qi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function ss(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=Zi(Ki(e,"left"),!0),n=Zi(Ki(e,"right")),o=Zi(Ki(e,"top"),!0),a=Zi(Ki(e,"bottom")),r=Gi(e,"x"),l=Gi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ki(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);ts(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=Ji(l.concat(h),d);ss(r.fullSize,g,d,p),ss(l,g,d,p),ss(h,g,d,p)&&ss(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),os(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,os(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class rs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class ls extends rs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const hs="$chartjs",cs={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},ds=t=>null===t||""===t;const us=!!Se&&{passive:!0};function fs(t,e,i){t&&t.canvas&&t.canvas.removeEventListener(e,i,us)}function gs(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function ps(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.addedNodes,s),e=e&&!gs(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function ms(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.removedNodes,s),e=e&&!gs(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const bs=new Map;let xs=0;function _s(){const t=window.devicePixelRatio;t!==xs&&(xs=t,bs.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function ys(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){bs.size||window.addEventListener("resize",_s),bs.set(t,e)}(t,o),a}function vs(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){bs.delete(t),bs.size||window.removeEventListener("resize",_s)}(t)}function Ms(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=cs[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t&&t.addEventListener(e,i,us)}(s,e,n),n}class ws extends rs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[hs]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",ds(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(ds(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[hs])return!1;const i=e[hs].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[hs],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:ps,detach:ms,resize:ys}[e]||Ms;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:vs,detach:vs,resize:vs}[e]||fs)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=t&&ge(t);return!(!e||!e.isConnected)}}function ks(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?ls:ws}var Ss=Object.freeze({__proto__:null,BasePlatform:rs,BasicPlatform:ls,DomPlatform:ws,_detectPlatform:ks});const Ps="transparent",Ds={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Ps),n=s.valid&&Qt(e||Ps);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class Cs{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Ds[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new Cs(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(xt.add(this._chart,i),!0):void 0}}function As(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Ts(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function zs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Vs(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Bs=t=>"reset"===t||"none"===t,Ws=(t,e)=>e?t:Object.assign({},t);class Ns{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Es(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Vs(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Fs(t,"x")),o=e.yAxisID=l(i.yAxisID,Fs(t,"y")),a=e.rAxisID=l(i.rAxisID,Fs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Vs(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e)){const t=this._cachedMeta;this._data=function(t,e){const{iScale:i,vScale:s}=e,n="x"===i.axis?"x":"y",o="x"===s.axis?"x":"y",a=Object.keys(t),r=new Array(a.length);let l,h,c;for(l=0,h=a.length;l0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:Ts(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s=0&&tthis.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Ws(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Os(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Bs(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Bs(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Bs(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function js(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;io)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for($s(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t"top"===e||"left"===e?t[e]+i:t[e]-i,Us=(t,e)=>Math.min(e||t,t);function Xs(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Ks(t){return t.drawTicks?t.tickLength:0}function Gs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Zs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class Js extends Hs{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;rs?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=J(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Ks(t.grid)-e.padding-Gs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(J((h.highest.height+6)/o,-1,1)),Math.asin(J(a/r,-1,1))-Math.asin(J(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Gs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Ks(n)+o):(t.height=this.maxHeight,t.width=Ks(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Ks(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,b=function(t){return Ae(i,t,p)};let x,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)x=b(this.bottom),w=this.bottom-u,S=x-m,D=b(t.top)+m,O=t.bottom;else if("bottom"===a)x=b(this.top),D=t.top,O=b(t.bottom)-m,w=x+m,S=this.top+u;else if("left"===a)x=b(this.right),M=this.right-u,k=x-m,P=b(t.left)+m,C=t.right;else if("right"===a)x=b(this.left),P=t.left,C=b(t.right)-m,M=x+m,k=this.left+u;else if("x"===e){if("center"===a)x=b((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=x+m,S=w+u}else if("y"===e){if("center"===a)x=b((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}M=x-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_0&&(o-=s/2)}d={left:o,top:n,width:s+e.width,height:i+e.height,color:t.backdropColor}}b.push({label:v,font:P,textOffset:O,options:{rotation:m,color:i,strokeColor:o,strokeWidth:h,textAlign:f,textBaseline:A,translation:[M,w],backdrop:d}})}return b}_getXAxisLabelAlignment(){const{position:t,ticks:e}=this.options;if(-$(this.labelRotation))return"top"===t?"left":"right";let i="center";return"start"===e.align?i="left":"end"===e.align?i="right":"inner"===e.align&&(i="inner"),i}_getYAxisLabelAlignment(t){const{position:e,ticks:{crossAlign:i,mirror:s,padding:n}}=this.options,o=t+n,a=this._getLabelSizes().widest.width;let r,l;return"left"===e?s?(l=this.right+n,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l+=a)):(l=this.right-o,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l=this.left)):"right"===e?s?(l=this.left+n,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l-=a)):(l=this.left+o,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l=this.right)):r="right",{textAlign:r,x:l}}_computeLabelArea(){if(this.options.ticks.mirror)return;const t=this.chart,e=this.options.position;return"left"===e||"right"===e?{top:0,left:this.left,bottom:t.height,right:this.right}:"top"===e||"bottom"===e?{top:this.top,left:0,bottom:this.bottom,right:t.width}:void 0}drawBackground(){const{ctx:t,options:{backgroundColor:e},left:i,top:s,width:n,height:o}=this;e&&(t.save(),t.fillStyle=e,t.fillRect(i,s,n,o),t.restore())}getLineWidthForValue(t){const e=this.options.grid;if(!this._isVisible()||!e.display)return 0;const i=this.ticks.findIndex((e=>e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class tn{constructor(){this.controllers=new Qs(Ns,"datasets",!0),this.elements=new Qs(Hs,"elements"),this.plugins=new Qs(Object,"plugins"),this.scales=new Qs(Js,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function nn(t,e){return e||!1!==t?!0===t?{}:t:null}function on(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function an(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function rn(t){if("x"===t||"y"===t||"r"===t)return t}function ln(t,...e){if(rn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&rn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function hn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function cn(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=an(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=ln(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return hn(t,"x",i[0])||hn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=x(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||an(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),x(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];x(e,[ue.scales[e.type],ue.scale])})),a}function dn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=cn(t,e)}function un(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const fn=new Map,gn=new Set;function pn(t,e){let i=fn.get(t);return i||(i=e(),fn.set(t,i),gn.add(i)),i}const mn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class bn{constructor(t){this._config=function(t){return(t=t||{}).data=un(t.data),dn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=un(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),dn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return pn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return pn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return pn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return pn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>mn(r,t,e)))),e.forEach((t=>mn(r,s,t))),e.forEach((t=>mn(r,re[n]||{},t))),e.forEach((t=>mn(r,ue,t))),e.forEach((t=>mn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),gn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=xn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||_n(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=xn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function xn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const _n=t=>o(t)&&Object.getOwnPropertyNames(t).some((e=>S(t[e])));const yn=["top","bottom","left","right","chartArea"];function vn(t,e){return"top"===t||"bottom"===t||-1===yn.indexOf(t)&&"x"===e}function Mn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function wn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function kn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Sn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Pn={},Dn=t=>{const e=Sn(t);return Object.values(Pn).filter((t=>t.canvas===e)).pop()};function Cn(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}function On(t,e,i){return t.options.clip?t[i]:e[i]}class An{static defaults=ue;static instances=Pn;static overrides=re;static registry=en;static version="4.4.7";static getChart=Dn;static register(...t){en.add(...t),Tn()}static unregister(...t){en.remove(...t),Tn()}constructor(t,e){const s=this.config=new bn(e),n=Sn(t),o=Dn(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||ks(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new sn,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Pn[this.id]=this,r&&l?(xt.listen(this,"complete",wn),xt.listen(this,"progress",kn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return en}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return xt.stop(this),this}resize(t,e){xt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=ln(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=ln(o,n),r=l(n.type,e.dtype);void 0!==n.position&&vn(n.position,a)===vn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(en.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{as.configure(this,t,t.options),as.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(Mn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{as.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){Cn(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;as.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i=t._clip,s=!i.disabled,n=function(t,e){const{xScale:i,yScale:s}=t;return i&&s?{left:On(i,e,"left"),right:On(i,e,"right"),top:On(s,e,"top"),bottom:On(s,e,"bottom")}:e}(t,this.chartArea),o={meta:t,index:t.index,cancelable:!0};!1!==this.notifyPlugins("beforeDatasetDraw",o)&&(s&&Ie(e,{left:!1===i.left?0:n.left-i.left,right:!1===i.right?this.width:n.right+i.right,top:!1===i.top?0:n.top-i.top,bottom:!1===i.bottom?this.height:n.bottom+i.bottom}),t.controller.draw(),s&&ze(e),o.cancelable=!1,this.notifyPlugins("afterDatasetDraw",o))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Xi.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),xt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Tn(){return u(An.instances,(t=>t._plugins.invalidate()))}function Ln(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class En{static override(t){Object.assign(En.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return Ln()}parse(){return Ln()}format(){return Ln()}add(){return Ln()}diff(){return Ln()}startOf(){return Ln()}endOf(){return Ln()}}var Rn={_date:En};function In(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Fn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.base"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i,color:s}}=t.legend.options;return e.labels.map(((e,n)=>{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;nZ(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>Z(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),b=g(C,h,d),x=g(C+E,c,u);s=(p-b)/2,n=(m-x)/2,o=-(p+b)/2,a=-(m+x)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),b=(i.width-o)/f,x=(i.height-o)/g,_=Math.max(Math.min(b,x)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Yn=Object.freeze({__proto__:null,BarController:class extends Ns{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Fn(t,e,i,s)}parseArrayData(t,e,i,s){return Fn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;dt.controller.options.grouped)),o=i.options.stacked,a=[],r=this._cachedMeta.controller.getParsed(e),l=r&&r[i.axis],h=t=>{const e=t._parsed.find((t=>t[i.axis]===l)),n=e&&e[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!h(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(u,e,r)*a,f===r&&(b-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);b=Math.max(Math.min(b,h),o),d=b+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(b))}if(b===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;b+=t,u-=t}return{size:u,base:b,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,l="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=b?g:{};if(i=x){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),b||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends jn{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:$n,RadarController:class extends Ns{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(i[f]-_[f])>b,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),x||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Un(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return J(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:J(n.innerStart,0,a),innerEnd:J(n.innerEnd,0,a)}}function Xn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function qn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,b=n-p-f,{outerStart:x,outerEnd:_,innerStart:y,innerEnd:v}=Un(e,u,d,b-m),M=d-x,w=d-_,k=m+x/M,S=b-_/w,P=u+y,D=u+v,O=m+y/P,A=b-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=Xn(w,S,a,r);t.arc(e.x,e.y,_,S,b+E)}const i=Xn(D,b,a,r);if(t.lineTo(i.x,i.y),v>0){const e=Xn(D,A,a,r);t.arc(e.x,e.y,v,b+E,A+Math.PI)}const s=(b-v/u+(m+y/u))/2;if(t.arc(a,r,u,b-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=Xn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=Xn(M,m,a,r);if(t.lineTo(n.x,n.y),x>0){const e=Xn(M,k,a,r);t.arc(e.x,e.y,x,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Kn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u}=l,f="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,f?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let g=e.endAngle;if(o){qn(t,e,i,s,g,n);for(let e=0;en?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,g),o||(qn(t,e,i,s,g,n),t.stroke())}function Gn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Jn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=nr&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[x(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[x(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(b*m+e)/++b):(_(),t.lineTo(e,i),u=s,b=0,f=g=i),p=i}_()}function eo(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?to:Qn}const io="function"==typeof Path2D;function so(t,e,i,s){io&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Gn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=eo(e);for(const r of n)Gn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class no extends Hs{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a),g=Z(n,a,r)&&a!==r,p=f>=O||g,m=tt(o,h+u,c+u);return p&&m}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){qn(t,e,i,s,l,n);for(let e=0;e("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function po(t){const e=this.getLabels();return t>=0&&ts=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,b=!s(a),x=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!b&&!x)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),b&&x&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=b?a:M,w=x?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(b&&(u&&M!==a?(i.push({value:a}),Mr)break;i.push({value:t})}return x&&u&&w!==r?i.length&&V(i[i.length-1].value,r,mo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):x&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class xo extends bo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const _o=t=>Math.floor(z(t)),yo=(t,e)=>Math.pow(10,_o(t)+e);function vo(t){return 1===t/Math.pow(10,_o(t))}function Mo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function wo(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=_o(e);let o=function(t,e){let i=_o(e-t);for(;Mo(t,e,i)>10;)i++;for(;Mo(t,e,i)<10;)i--;return Math.min(i,_o(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:vo(g),significand:u}),s}class ko extends Js{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=bo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===yo(this.min,0)?yo(this.min,-1):yo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(yo(i,-1)),o(yo(s,1)))),i<=0&&n(yo(s,-1)),s<=0&&o(yo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=wo({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function So(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Po(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:tn?{start:e-i,end:e}:{start:e,end:e+i}}function Do(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Oo(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function Ao(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function To(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Lo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;ot,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(So(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/So(this.options))}generateTickLabels(t){bo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Do(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));To(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e||0===e&&this.min<0){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash||[]),o.lineDashOffset=n.dashOffset,o.beginPath(),Lo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&this.min>=0&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Ro={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},Io=Object.keys(Ro);function zo(t,e){return t-e}function Fo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Vo(t,e,i,s){const n=Io.length;for(let o=Io.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function Wo(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class No extends Js{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new Rn._date(t.adapters.date);s.init(e),x(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Fo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Vo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=Io.length-1;o>=Io.indexOf(i);o--){const i=Io[o];if(Ro[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return Io[i?Io.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=Io.indexOf(t)+1,i=Io.length;e+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=J(s,0,o),n=J(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Vo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var jo=Object.freeze({__proto__:null,CategoryScale:class extends Js{static id="category";static defaults={ticks:{callback:po}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:J(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:go(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return po.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:xo,LogarithmicScale:ko,RadialLinearScale:Eo,TimeScale:No,TimeSeriesScale:class extends No{static id="timeseries";static defaults=No.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=Ho(e,this.min),this._tableRange=Ho(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;ot-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(Ho(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return Ho(this._table,i*this._tableRange+this._minPos,!0)}}});const $o=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Yo=$o.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Uo(t){return $o[t%$o.length]}function Xo(t){return Yo[t%Yo.length]}function qo(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof jn?e=function(t,e){return t.backgroundColor=t.data.map((()=>Uo(e++))),e}(i,e):n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Uo(e),t.backgroundColor=Xo(e),++e}(i,e))}}function Ko(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Go={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n,a=Ko(s)||(r=n)&&(r.borderColor||r.backgroundColor)||o&&Ko(o)||"rgba(0,0,0,0.1)"!==ue.borderColor||"rgba(0,0,0,0.1)"!==ue.backgroundColor;var r;if(!i.forceOverride&&a)return;const l=qo(t);s.forEach(l)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Jo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var Qo={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Jo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=J(it(e,o.axis,a).lo,0,i-1)),s=h?J(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const b=[],x=e+i-1,_=t[e].x,y=t[x].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&b.push({...t[e],x:p}),s!==u&&s!==i&&b.push({...t[s],x:p})}o>0&&i!==u&&b.push(t[i]),b.push(a),h=e,m=0,f=g=l,c=d=u=o}}return b}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Jo(t)}};function ta(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ea(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function ia(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function sa(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ea(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new no({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function na(t){return t&&!1!==t.fill}function oa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function aa(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function ra(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&da(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;na(i)&&da(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;na(s)&&"beforeDatasetDraw"===i.drawTime&&da(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const ba=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class xa extends Hs{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=ba(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=_a(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=ba(o,d),b=this.isHorizontal(),x=this._computeTitleHeight();f=b?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+x,line:0}:{x:this.left+c,y:ft(n,this.top+x+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),b?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+x+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,b?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),b)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=_a(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class va extends Hs{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var Ma={id:"title",_element:va,start(t,e,i){!function(t,e){const i=new va({ctx:t.ctx,options:e,chart:t});as.configure(t,i,e),as.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;as.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const wa=new WeakMap;var ka={id:"subtitle",start(t,e,i){const s=new va({ctx:t.ctx,options:i,chart:t});as.configure(t,s,i),as.addBox(t,s),wa.set(t,s)},stop(t){as.removeBox(t,wa.get(t)),wa.delete(t)},beforeUpdate(t,e,i){const s=wa.get(t);as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa={average(t){if(!t.length)return!1;let e,i,s=new Set,n=0,o=0;for(e=0,i=t.length;et+e))/s.size,y:n/o}},nearest(t,e){if(!t.length)return!1;let i,s,n,o=e.x,a=e.y,r=Number.POSITIVE_INFINITY;for(i=0,s=t.length;i-1?t.split("\n"):t}function Ca(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Oa(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,b=0,x=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(x+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),x){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(x-g)*l.lineHeight+(x-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){b=Math.max(b,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),b+=p.width,{width:b,height:m}}function Aa(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ta(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||Aa(t,e,i,s),yAlign:s}}function La(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:J(g,0,s.width-e.width),y:J(p,0,s.height-e.height)}}function Ea(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function Ra(t){return Pa([],Da(t))}function Ia(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const za={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex{const e={before:[],lines:[],after:[]},n=Ia(i,t);Pa(e.before,Da(Fa(n,"beforeLabel",this,t))),Pa(e.lines,Fa(n,"label",this,t)),Pa(e.after,Da(Fa(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return Ra(Fa(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Fa(i,"beforeFooter",this,t),n=Fa(i,"footer",this,t),o=Fa(i,"afterFooter",this,t);let a=[];return a=Pa(a,Da(s)),a=Pa(a,Da(n)),a=Pa(a,Da(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Ia(t.callbacks,e);s.push(Fa(i,"labelColor",this,e)),n.push(Fa(i,"labelPointStyle",this,e)),o.push(Fa(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Sa[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Oa(this,i),a=Object.assign({},t,e),r=Ta(this.chart,i,a),l=La(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,b,x,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,x=_+o,y=_-o):(p=d+f,m=p+o,x=_-o,y=_+o),b=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(x=u,_=x-o,p=m-o,b=m+o):(x=u+g,_=x+o,p=m+o,b=m-o),y=x),{x1:p,x2:m,x3:b,y1:x,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ea(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let b,x,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ea(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Sa[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Oa(this,t),a=Object.assign({},i,this._size),r=Ta(e,t,a),l=La(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e.filter((t=>this.chart.data.datasets[t.datasetIndex]&&void 0!==this.chart.getDatasetMeta(t.datasetIndex).controller.getParsed(t.index)));const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Sa[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Ba={id:"tooltip",_element:Va,positioners:Sa,afterInit(t,e,i){i&&(t.tooltip=new Va({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:za},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return An.register(Yn,jo,fo,t),An.helpers={...Wi},An._adapters=Rn,An.Animation=Cs,An.Animations=Os,An.animator=xt,An.controllers=en.controllers.items,An.DatasetController=Ns,An.Element=Hs,An.elements=fo,An.Interaction=Xi,An.layouts=as,An.platforms=Ss,An.Scale=Js,An.Ticks=ae,Object.assign(An,Yn,jo,fo,t,Ss),An.Chart=An,"undefined"!=typeof window&&(window.Chart=An),An})); -//# sourceMappingURL=chart.umd.js.map diff --git a/internal/dashboard/static/chartjs-plugin-annotation.min.js b/internal/dashboard/static/chartjs-plugin-annotation.min.js deleted file mode 100644 index 2ee2327..0000000 --- a/internal/dashboard/static/chartjs-plugin-annotation.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! -* chartjs-plugin-annotation v3.1.0 -* https://www.chartjs.org/chartjs-plugin-annotation/index - * (c) 2024 chartjs-plugin-annotation Contributors - * Released under the MIT License - */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("chart.js"),require("chart.js/helpers")):"function"==typeof define&&define.amd?define(["chart.js","chart.js/helpers"],e):(t="undefined"!=typeof globalThis?globalThis:t||self)["chartjs-plugin-annotation"]=e(t.Chart,t.Chart.helpers)}(this,(function(t,e){"use strict";const o={modes:{point:(t,e)=>i(t,e,{intersect:!0}),nearest:(t,o,n)=>function(t,o,n){let r=Number.POSITIVE_INFINITY;return i(t,o,n).reduce(((t,i)=>{const s=i.getCenterPoint(),a=function(t,e,o){if("x"===o)return{x:t.x,y:e.y};if("y"===o)return{x:e.x,y:t.y};return e}(o,s,n.axis),d=e.distanceBetweenPoints(o,a);return dt._index-e._index)).slice(0,1)}(t,o,n),x:(t,e,o)=>i(t,e,{intersect:o.intersect,axis:"x"}),y:(t,e,o)=>i(t,e,{intersect:o.intersect,axis:"y"})}};function n(t,e,n){return(o.modes[n.mode]||o.modes.nearest)(t,e,n)}function i(t,e,o){return t.filter((t=>o.intersect?t.inRange(e.x,e.y):function(t,e,o){return"x"!==o&&"y"!==o?t.inRange(e.x,e.y,"x",!0)||t.inRange(e.x,e.y,"y",!0):t.inRange(e.x,e.y,o,!0)}(t,e,o.axis)))}function r(t,e,o){const n=Math.cos(o),i=Math.sin(o),r=e.x,s=e.y;return{x:r+n*(t.x-r)-i*(t.y-s),y:s+i*(t.x-r)+n*(t.y-s)}}const s=(t,e)=>e>t||t.length>e.length&&t.slice(0,e.length)===e,a=.001,d=(t,e,o)=>Math.min(o,Math.max(e,t)),l=(t,e)=>t.value>=t.start-e&&t.value<=t.end+e;function c(t,e,o){for(const n of Object.keys(t))t[n]=d(t[n],e,o);return t}function h(t,{x:e,y:o,x2:n,y2:i},r,{borderWidth:s,hitTolerance:d}){const l=(s+d)/2,c=t.x>=e-l-a&&t.x<=n+l+a,h=t.y>=o-l-a&&t.y<=i+l+a;return"x"===r?c:("y"===r||c)&&h}function u(t,{rect:o,center:n},i,{rotation:s,borderWidth:a,hitTolerance:d}){return h(r(t,n,e.toRadians(-s)),o,i,{borderWidth:a,hitTolerance:d})}function f(t,e){const{centerX:o,centerY:n}=t.getProps(["centerX","centerY"],e);return{x:o,y:n}}const x=t=>"string"==typeof t&&t.endsWith("%"),y=t=>parseFloat(t)/100,p=t=>d(y(t),0,1),b=(t,e)=>({x:t,y:e,x2:t,y2:e,width:0,height:0}),g={box:t=>b(t.centerX,t.centerY),doughnutLabel:t=>b(t.centerX,t.centerY),ellipse:t=>({centerX:t.centerX,centerY:t.centerX,radius:0,width:0,height:0}),label:t=>b(t.centerX,t.centerY),line:t=>b(t.x,t.y),point:t=>({centerX:t.centerX,centerY:t.centerY,radius:0,width:0,height:0}),polygon:t=>b(t.centerX,t.centerY)};function m(t,e){return"start"===e?0:"end"===e?t:x(e)?p(e)*t:t/2}function v(t,e,o=!0){return"number"==typeof e?e:x(e)?(o?p(e):y(e))*t:t}function w(t,o,{borderWidth:n,position:i,xAdjust:r,yAdjust:s},a){const d=e.isObject(a),l=o.width+(d?a.width:0)+n,c=o.height+(d?a.height:0)+n,h=M(i),u=k(t.x,l,r,h.x),f=k(t.y,c,s,h.y);return{x:u,y:f,x2:u+l,y2:f+c,width:l,height:c,centerX:u+l/2,centerY:f+c/2}}function M(t,o="center"){return e.isObject(t)?{x:e.valueOrDefault(t.x,o),y:e.valueOrDefault(t.y,o)}:{x:t=e.valueOrDefault(t,o),y:t}}const P=(t,e)=>t&&t.autoFit&&e<1;function S(t,o){const n=t.font,i=e.isArray(n)?n:[n];return P(t,o)?i.map((function(t){const n=e.toFont(t);return n.size=Math.floor(t.size*o),n.lineHeight=t.lineHeight,e.toFont(n)})):i.map((t=>e.toFont(t)))}function C(t){return t&&(e.defined(t.xValue)||e.defined(t.yValue))}function k(t,e,o=0,n){return t-m(e,n)+o}function A(t,o,n){const i=n.init;if(i)return!0===i?D(o,n):function(t,o,n){const i=e.callback(n.init,[{chart:t,properties:o,options:n}]);if(!0===i)return D(o,n);if(e.isObject(i))return i}(t,o,n)}function T(t,o,n){let i=!1;return o.forEach((o=>{e.isFunction(t[o])?(i=!0,n[o]=t[o]):e.defined(n[o])&&delete n[o]})),i}function D(t,e){const o=e.type||"line";return g[o](t)}const j=new Map,O=t=>isNaN(t)||t<=0,R=t=>t.reduce((function(t,e){return t+=e.string}),"");function I(t){if(t&&"object"==typeof t){const e=t.toString();return"[object HTMLImageElement]"===e||"[object HTMLCanvasElement]"===e}}function Y(t,{x:o,y:n},i){i&&(t.translate(o,n),t.rotate(e.toRadians(i)),t.translate(-o,-n))}function X(t,e){if(e&&e.borderWidth)return t.lineCap=e.borderCapStyle||"butt",t.setLineDash(e.borderDash),t.lineDashOffset=e.borderDashOffset,t.lineJoin=e.borderJoinStyle||"miter",t.lineWidth=e.borderWidth,t.strokeStyle=e.borderColor,!0}function _(t,e){t.shadowColor=e.backgroundShadowColor,t.shadowBlur=e.shadowBlur,t.shadowOffsetX=e.shadowOffsetX,t.shadowOffsetY=e.shadowOffsetY}function E(t,o){const n=o.content;if(I(n)){return{width:v(n.width,o.width),height:v(n.height,o.height)}}const i=S(o),r=o.textStrokeWidth,s=e.isArray(n)?n:[n],a=s.join()+R(i)+r+(t._measureText?"-spriting":"");return j.has(a)||j.set(a,function(t,e,o,n){t.save();const i=e.length;let r=0,s=n;for(let a=0;a0)return t.lineJoin="round",t.miterLimit=2,t.lineWidth=e.textStrokeWidth,t.strokeStyle=e.textStrokeColor,!0}(t,n)&&function(t,{x:e,y:o},n,i){t.beginPath();let r=0;n.forEach((function(n,s){const a=i[Math.min(s,i.length-1)],d=a.lineHeight;t.font=a.string,t.strokeText(n,e,o+d/2+r),r+=d})),t.stroke()}(t,{x:h,y:u},s,a),function(t,{x:e,y:o},n,{fonts:i,colors:r}){let s=0;n.forEach((function(n,a){const d=r[Math.min(a,r.length-1)],l=i[Math.min(a,i.length-1)],c=l.lineHeight;t.beginPath(),t.font=l.string,t.fillStyle=d,t.fillText(n,e,o+c/2+s),s+=c,t.fill()}))}(t,{x:h,y:u},s,{fonts:a,colors:c}),t.restore()}function F(t,o,n,i){const{radius:r,options:s}=o,a=s.pointStyle,d=s.rotation;let l=(d||0)*e.RAD_PER_DEG;if(I(a))return t.save(),t.translate(n,i),t.rotate(l),t.drawImage(a,-a.width/2,-a.height/2,a.width,a.height),void t.restore();O(r)||function(t,{x:o,y:n,radius:i,rotation:r,style:s,rad:a}){let d,l,c,h;switch(t.beginPath(),s){default:t.arc(o,n,i,0,e.TAU),t.closePath();break;case"triangle":t.moveTo(o+Math.sin(a)*i,n-Math.cos(a)*i),a+=e.TWO_THIRDS_PI,t.lineTo(o+Math.sin(a)*i,n-Math.cos(a)*i),a+=e.TWO_THIRDS_PI,t.lineTo(o+Math.sin(a)*i,n-Math.cos(a)*i),t.closePath();break;case"rectRounded":h=.516*i,c=i-h,d=Math.cos(a+e.QUARTER_PI)*c,l=Math.sin(a+e.QUARTER_PI)*c,t.arc(o-d,n-l,h,a-e.PI,a-e.HALF_PI),t.arc(o+l,n-d,h,a-e.HALF_PI,a),t.arc(o+d,n+l,h,a,a+e.HALF_PI),t.arc(o-l,n+d,h,a+e.HALF_PI,a+e.PI),t.closePath();break;case"rect":if(!r){c=Math.SQRT1_2*i,t.rect(o-c,n-c,2*c,2*c);break}a+=e.QUARTER_PI;case"rectRot":d=Math.cos(a)*i,l=Math.sin(a)*i,t.moveTo(o-d,n-l),t.lineTo(o+l,n-d),t.lineTo(o+d,n+l),t.lineTo(o-l,n+d),t.closePath();break;case"crossRot":a+=e.QUARTER_PI;case"cross":d=Math.cos(a)*i,l=Math.sin(a)*i,t.moveTo(o-d,n-l),t.lineTo(o+d,n+l),t.moveTo(o+l,n-d),t.lineTo(o-l,n+d);break;case"star":d=Math.cos(a)*i,l=Math.sin(a)*i,t.moveTo(o-d,n-l),t.lineTo(o+d,n+l),t.moveTo(o+l,n-d),t.lineTo(o-l,n+d),a+=e.QUARTER_PI,d=Math.cos(a)*i,l=Math.sin(a)*i,t.moveTo(o-d,n-l),t.lineTo(o+d,n+l),t.moveTo(o+l,n-d),t.lineTo(o-l,n+d);break;case"line":d=Math.cos(a)*i,l=Math.sin(a)*i,t.moveTo(o-d,n-l),t.lineTo(o+d,n+l);break;case"dash":t.moveTo(o,n),t.lineTo(o+Math.cos(a)*i,n+Math.sin(a)*i)}t.fill()}(t,{x:n,y:i,radius:r,rotation:d,style:a,rad:l})}const H=["left","bottom","top","right"];function N(t,o){const{pointX:n,pointY:i,options:s}=o,a=s.callout,d=a&&a.display&&function(t,o){const n=o.position;if(H.includes(n))return n;return function(t,o){const{x:n,y:i,x2:s,y2:a,width:d,height:l,pointX:c,pointY:h,centerX:u,centerY:f,rotation:x}=t,y={x:u,y:f},p=o.start,b=v(d,p),g=v(l,p),m=[n,n+b,n+b,s],w=[i+g,a,i,a],M=[];for(let t=0;t<4;t++){const o=r({x:m[t],y:w[t]},y,e.toRadians(x));M.push({position:H[t],distance:e.distanceBetweenPoints(o,{x:c,y:h})})}return M.sort(((t,e)=>t.distance-e.distance))[0].position}(t,o)}(o,a);if(!d||function(t,e,o){const{pointX:n,pointY:i}=t,r=e.margin;let s=n,a=i;"left"===o?s+=r:"right"===o?s-=r:"top"===o?a+=r:"bottom"===o&&(a-=r);return t.inRange(s,a)}(o,a,d))return;t.save(),t.beginPath();if(!X(t,a))return t.restore();const{separatorStart:l,separatorEnd:c}=function(t,e){const{x:o,y:n,x2:i,y2:r}=t,s=function(t,e){const{width:o,height:n,options:i}=t,r=i.callout.margin+i.borderWidth/2;if("right"===e)return o+r;if("bottom"===e)return n+r;return-r}(t,e);let a,d;"left"===e||"right"===e?(a={x:o+s,y:n},d={x:a.x,y:r}):(a={x:o,y:n+s},d={x:i,y:a.y});return{separatorStart:a,separatorEnd:d}}(o,d),{sideStart:h,sideEnd:u}=function(t,e,o){const{y:n,width:i,height:r,options:s}=t,a=s.callout.start,d=function(t,e){const o=e.side;if("left"===t||"top"===t)return-o;return o}(e,s.callout);let l,c;"left"===e||"right"===e?(l={x:o.x,y:n+v(r,a)},c={x:l.x+d,y:l.y}):(l={x:o.x+v(i,a),y:o.y},c={x:l.x,y:l.y+d});return{sideStart:l,sideEnd:c}}(o,d,l);(a.margin>0||0===s.borderWidth)&&(t.moveTo(l.x,l.y),t.lineTo(c.x,c.y)),t.moveTo(h.x,h.y),t.lineTo(u.x,u.y);const f=r({x:n,y:i},o.getCenterPoint(),e.toRadians(-o.rotation));t.lineTo(f.x,f.y),t.stroke(),t.restore()}const L={xScaleID:{min:"xMin",max:"xMax",start:"left",end:"right",startProp:"x",endProp:"x2"},yScaleID:{min:"yMin",max:"yMax",start:"bottom",end:"top",startProp:"y",endProp:"y2"}};function V(t,o,n){return o="number"==typeof o?o:t.parse(o),e.isFinite(o)?t.getPixelForValue(o):n}function B(t,e,o){const n=e[o];if(n||"scaleID"===o)return n;const i=o.charAt(0),r=Object.values(t).filter((t=>t.axis&&t.axis===i));return r.length?r[0].id:i}function $(t,e){if(t){const o=t.options.reverse;return{start:V(t,e.min,o?e.end:e.start),end:V(t,e.max,o?e.start:e.end)}}}function U(t,e){const{chartArea:o,scales:n}=t,i=n[B(n,e,"xScaleID")],r=n[B(n,e,"yScaleID")];let s=o.width/2,a=o.height/2;return i&&(s=V(i,e.xValue,i.left+i.width/2)),r&&(a=V(r,e.yValue,r.top+r.height/2)),{x:s,y:a}}function J(t,e){const o=t.scales,n=o[B(o,e,"xScaleID")],i=o[B(o,e,"yScaleID")];if(!n&&!i)return{};let{left:r,right:s}=n||t.chartArea,{top:a,bottom:d}=i||t.chartArea;const l=K(n,{min:e.xMin,max:e.xMax,start:r,end:s});r=l.start,s=l.end;const c=K(i,{min:e.yMin,max:e.yMax,start:d,end:a});return a=c.start,d=c.end,{x:r,y:a,x2:s,y2:d,width:s-r,height:d-a,centerX:r+(s-r)/2,centerY:a+(d-a)/2}}function q(t,e){if(!C(e)){const o=J(t,e);let n=e.radius;n&&!isNaN(n)||(n=Math.min(o.width,o.height)/2,e.radius=n);const i=2*n,r=o.centerX+e.xAdjust,s=o.centerY+e.yAdjust;return{x:r-n,y:s-n,x2:r+n,y2:s+n,centerX:r,centerY:s,width:i,height:i,radius:n}}return function(t,e){const o=U(t,e),n=2*e.radius;return{x:o.x-e.radius+e.xAdjust,y:o.y-e.radius+e.yAdjust,x2:o.x+e.radius+e.xAdjust,y2:o.y+e.radius+e.yAdjust,centerX:o.x+e.xAdjust,centerY:o.y+e.yAdjust,radius:e.radius,width:n,height:n}}(t,e)}function Q(t,e){const{scales:o,chartArea:n}=t,i=o[e.scaleID],r={x:n.left,y:n.top,x2:n.right,y2:n.bottom};return i?function(t,e,o){const n=V(t,o.value,NaN),i=V(t,o.endValue,n);t.isHorizontal()?(e.x=n,e.x2=i):(e.y=n,e.y2=i)}(i,r,e):function(t,e,o){for(const n of Object.keys(L)){const i=t[B(t,o,n)];if(i){const{min:t,max:r,start:s,end:a,startProp:d,endProp:l}=L[n],c=$(i,{min:o[t],max:o[r],start:i[s],end:i[a]});e[d]=c.start,e[l]=c.end}}}(o,r,e),r}function G(t,e){const o=J(t,e);return o.initProperties=A(t,o,e),o.elements=[{type:"label",optionScope:"label",properties:tt(t,o,e),initProperties:o.initProperties}],o}function K(t,e){const o=$(t,e)||e;return{start:Math.min(o.start,o.end),end:Math.max(o.start,o.end)}}function Z(t,e){const{start:o,end:n,borderWidth:i}=t,{position:r,padding:{start:s,end:a},adjust:d}=e;return o+i/2+d+m(n-i-o-s-a-e.size,r)}function tt(t,o,n){const i=n.label;i.backgroundColor="transparent",i.callout.display=!1;const r=M(i.position),s=e.toPadding(i.padding),a=E(t.ctx,i),d=function({properties:t,options:e},o,n,i){const{x:r,x2:s,width:a}=t;return Z({start:r,end:s,size:a,borderWidth:e.borderWidth},{position:n.x,padding:{start:i.left,end:i.right},adjust:e.label.xAdjust,size:o.width})}({properties:o,options:n},a,r,s),l=function({properties:t,options:e},o,n,i){const{y:r,y2:s,height:a}=t;return Z({start:r,end:s,size:a,borderWidth:e.borderWidth},{position:n.y,padding:{start:i.top,end:i.bottom},adjust:e.label.yAdjust,size:o.height})}({properties:o,options:n},a,r,s),c=a.width+s.width,h=a.height+s.height;return{x:d,y:l,x2:d+c,y2:l+h,width:c,height:h,centerX:d+c/2,centerY:l+h/2,rotation:i.rotation}}const et=["enter","leave"],ot=et.concat("click");function nt(t,e,o){if(t.listened)switch(e.type){case"mousemove":case"mouseout":return function(t,e,o){if(!t.moveListened)return;let i;i="mousemove"===e.type?n(t.visibleElements,e,o.interaction):[];const r=t.hovered;t.hovered=i;const s={state:t,event:e};let a=it(s,"leave",r,i);return it(s,"enter",i,r)||a}(t,e,o);case"click":return function(t,e,o){const i=t.listeners,r=n(t.visibleElements,e,o.interaction);let s;for(const t of r)s=rt(t.options.click||i.click,t,e)||s;return s}(t,e,o)}}function it({state:t,event:e},o,n,i){let r;for(const s of n)i.indexOf(s)<0&&(r=rt(s.options[o]||t.listeners[o],s,e)||r);return r}function rt(t,o,n){return!0===e.callback(t,[o.$context,n])}const st=["afterDraw","beforeDraw"];function at(t,o,n){if(t.hooked){const i=o.options[n]||t.hooks[n];return e.callback(i,[o.$context])}}function dt(t,o,n){const i=function(t,o,n){const i=o.axis,r=o.id,s=i+"ScaleID",a={min:e.valueOrDefault(o.min,Number.NEGATIVE_INFINITY),max:e.valueOrDefault(o.max,Number.POSITIVE_INFINITY)};for(const e of n)e.scaleID===r?ut(e,o,["value","endValue"],a):B(t,e,s)===r&&ut(e,o,[i+"Min",i+"Max",i+"Value"],a);return a}(t.scales,o,n);let r=lt(o,i,"min","suggestedMin");r=lt(o,i,"max","suggestedMax")||r,r&&e.isFunction(o.handleTickRangeOptions)&&o.handleTickRangeOptions()}function lt(t,o,n,i){if(e.isFinite(o[n])&&!function(t,o,n){return e.defined(t[o])||e.defined(t[n])}(t.options,n,i)){const e=t[n]!==o[n];return t[n]=o[n],e}}function ct(t,e){for(const o of["scaleID","xScaleID","yScaleID"]){const n=B(e,t,o);n&&!e[n]&&ht(t,o)&&console.warn(`No scale found with id '${n}' for annotation '${t.id}'`)}}function ht(t,o){if("scaleID"===o)return!0;const n=o.charAt(0);for(const o of["Min","Max","Value"])if(e.defined(t[n+o]))return!0;return!1}function ut(t,o,n,i){for(const r of n){const n=t[r];if(e.defined(n)){const t=o.parse(n);i.min=Math.min(i.min,t),i.max=Math.max(i.max,t)}}}class ft extends t.Element{inRange(t,o,n,i){const{x:s,y:a}=r({x:t,y:o},this.getCenterPoint(i),e.toRadians(-this.options.rotation));return h({x:s,y:a},this.getProps(["x","y","x2","y2"],i),n,this.options)}getCenterPoint(t){return f(this,t)}draw(t){t.save(),Y(t,this.getCenterPoint(),this.options.rotation),W(t,this,this.options),t.restore()}get label(){return this.elements&&this.elements[0]}resolveElementProperties(t,e){return G(t,e)}}ft.id="boxAnnotation",ft.defaults={adjustScaleRange:!0,backgroundShadowColor:"transparent",borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderRadius:0,borderShadowColor:"transparent",borderWidth:1,display:!0,init:void 0,hitTolerance:0,label:{backgroundColor:"transparent",borderWidth:0,callout:{display:!1},color:"black",content:null,display:!1,drawTime:void 0,font:{family:void 0,lineHeight:void 0,size:void 0,style:void 0,weight:"bold"},height:void 0,hitTolerance:void 0,opacity:void 0,padding:6,position:"center",rotation:void 0,textAlign:"start",textStrokeColor:void 0,textStrokeWidth:0,width:void 0,xAdjust:0,yAdjust:0,z:void 0},rotation:0,shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,xMax:void 0,xMin:void 0,xScaleID:void 0,yMax:void 0,yMin:void 0,yScaleID:void 0,z:0},ft.defaultRoutes={borderColor:"color",backgroundColor:"color"},ft.descriptors={label:{_fallback:!0}};class xt extends t.Element{inRange(t,e,o,n){return u({x:t,y:e},{rect:this.getProps(["x","y","x2","y2"],n),center:this.getCenterPoint(n)},o,{rotation:this.rotation,borderWidth:0,hitTolerance:this.options.hitTolerance})}getCenterPoint(t){return f(this,t)}draw(t){const e=this.options;e.display&&e.content&&(!function(t,e){const{_centerX:o,_centerY:n,_radius:i,_startAngle:r,_endAngle:s,_counterclockwise:a,options:d}=e;t.save();const l=X(t,d);t.fillStyle=d.backgroundColor,t.beginPath(),t.arc(o,n,i,r,s,a),t.closePath(),t.fill(),l&&t.stroke();t.restore()}(t,this),t.save(),Y(t,this.getCenterPoint(),this.rotation),z(t,this,e,this._fitRatio),t.restore())}resolveElementProperties(o,n){const i=function(e,o){return e.getSortedVisibleDatasetMetas().reduce((function(n,i){const r=i.controller;return r instanceof t.DoughnutController&&function(t,e,o){if(!e.autoHide)return!0;for(let e=0;e=90?i:n}),void 0)}(o,n);if(!i)return{};const{controllerMeta:r,point:s,radius:a}=function({chartArea:t},o,n){const{left:i,top:r,right:s,bottom:a}=t,{innerRadius:d,offsetX:l,offsetY:c}=n.controller,h=(i+s)/2+l,u=(r+a)/2+c,f={left:Math.max(h-d,i),right:Math.min(h+d,s),top:Math.max(u-d,r),bottom:Math.min(u+d,a)},x={x:(f.left+f.right)/2,y:(f.top+f.bottom)/2},y=o.spacing+o.borderWidth/2,p=d-y,b=x.y>u,g=function(t,o,n,i){const r=Math.pow(n-t,2),s=Math.pow(i,2),a=-2*o,d=Math.pow(o,2)+r-s,l=Math.pow(a,2)-4*d;if(l<=0)return{_startAngle:0,_endAngle:e.TAU};const c=(-a-Math.sqrt(l))/2,h=(-a+Math.sqrt(l))/2;return{_startAngle:e.getAngleFromPoint({x:o,y:n},{x:c,y:t}).angle,_endAngle:e.getAngleFromPoint({x:o,y:n},{x:h,y:t}).angle}}(b?r+y:a-y,h,u,p),m={_centerX:h,_centerY:u,_radius:p,_counterclockwise:b,...g};return{controllerMeta:m,point:x,radius:Math.min(d,Math.min(f.right-f.left,f.bottom-f.top)/2)}}(o,n,i);let d=E(o.ctx,n);const l=function({width:t,height:e},o){const n=Math.sqrt(Math.pow(t,2)+Math.pow(e,2));return 2*o/n}(d,a);P(n,l)&&(d={width:d.width*l,height:d.height*l});const{position:c,xAdjust:h,yAdjust:u}=n,f=w(s,d,{borderWidth:0,position:c,xAdjust:h,yAdjust:u});return{initProperties:A(o,f,n),...f,...r,rotation:n.rotation,_fitRatio:l}}}xt.id="doughnutLabelAnnotation",xt.defaults={autoFit:!0,autoHide:!0,backgroundColor:"transparent",backgroundShadowColor:"transparent",borderColor:"transparent",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderShadowColor:"transparent",borderWidth:0,color:"black",content:null,display:!0,font:{family:void 0,lineHeight:void 0,size:void 0,style:void 0,weight:void 0},height:void 0,hitTolerance:0,init:void 0,opacity:void 0,position:"center",rotation:0,shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,spacing:1,textAlign:"center",textStrokeColor:void 0,textStrokeWidth:0,width:void 0,xAdjust:0,yAdjust:0},xt.defaultRoutes={};class yt extends t.Element{inRange(t,e,o,n){return u({x:t,y:e},{rect:this.getProps(["x","y","x2","y2"],n),center:this.getCenterPoint(n)},o,{rotation:this.rotation,borderWidth:this.options.borderWidth,hitTolerance:this.options.hitTolerance})}getCenterPoint(t){return f(this,t)}draw(t){const o=this.options,n=!e.defined(this._visible)||this._visible;o.display&&o.content&&n&&(t.save(),Y(t,this.getCenterPoint(),this.rotation),N(t,this),W(t,this,o),z(t,function({x:t,y:o,width:n,height:i,options:r}){const s=r.borderWidth/2,a=e.toPadding(r.padding);return{x:t+a.left+s,y:o+a.top+s,width:n-a.left-a.right-r.borderWidth,height:i-a.top-a.bottom-r.borderWidth}}(this),o),t.restore())}resolveElementProperties(t,o){let n;if(C(o))n=U(t,o);else{const{centerX:e,centerY:i}=J(t,o);n={x:e,y:i}}const i=e.toPadding(o.padding),r=w(n,E(t.ctx,o),o,i);return{initProperties:A(t,r,o),pointX:n.x,pointY:n.y,...r,rotation:o.rotation}}}yt.id="labelAnnotation",yt.defaults={adjustScaleRange:!0,backgroundColor:"transparent",backgroundShadowColor:"transparent",borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderRadius:0,borderShadowColor:"transparent",borderWidth:0,callout:{borderCapStyle:"butt",borderColor:void 0,borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:1,display:!1,margin:5,position:"auto",side:5,start:"50%"},color:"black",content:null,display:!0,font:{family:void 0,lineHeight:void 0,size:void 0,style:void 0,weight:void 0},height:void 0,hitTolerance:0,init:void 0,opacity:void 0,padding:6,position:"center",rotation:0,shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,textAlign:"center",textStrokeColor:void 0,textStrokeWidth:0,width:void 0,xAdjust:0,xMax:void 0,xMin:void 0,xScaleID:void 0,xValue:void 0,yAdjust:0,yMax:void 0,yMin:void 0,yScaleID:void 0,yValue:void 0,z:0},yt.defaultRoutes={borderColor:"color"};const pt=(t,e,o)=>({x:t.x+o*(e.x-t.x),y:t.y+o*(e.y-t.y)}),bt=(t,e,o)=>pt(e,o,Math.abs((t-e.y)/(o.y-e.y))).x,gt=(t,e,o)=>pt(e,o,Math.abs((t-e.x)/(o.x-e.x))).y,mt=t=>t*t,vt=(t,e,{x:o,y:n,x2:i,y2:r},s)=>"y"===s?{start:Math.min(n,r),end:Math.max(n,r),value:e}:{start:Math.min(o,i),end:Math.max(o,i),value:t},wt=(t,e,o,n)=>(1-n)*(1-n)*t+2*(1-n)*n*e+n*n*o,Mt=(t,e,o,n)=>({x:wt(t.x,e.x,o.x,n),y:wt(t.y,e.y,o.y,n)}),Pt=(t,e,o,n)=>2*(1-n)*(e-t)+2*n*(o-e),St=(t,o,n,i)=>-Math.atan2(Pt(t.x,o.x,n.x,i),Pt(t.y,o.y,n.y,i))+.5*e.PI;class Ct extends t.Element{inRange(t,e,o,n){const i=(this.options.borderWidth+this.options.hitTolerance)/2;if("x"!==o&&"y"!==o){const o={mouseX:t,mouseY:e},{path:r,ctx:s}=this;if(r){X(s,this.options),s.lineWidth+=this.options.hitTolerance;const{chart:i}=this.$context,a=t*i.currentDevicePixelRatio,d=e*i.currentDevicePixelRatio,l=s.isPointInStroke(r,a,d)||Tt(this,o,n);return s.restore(),l}return function(t,{mouseX:e,mouseY:o},n=a,i){const{x:r,y:s,x2:d,y2:l}=t.getProps(["x","y","x2","y2"],i),c=d-r,h=l-s,u=mt(c)+mt(h),f=0===u?-1:((e-r)*c+(o-s)*h)/u;let x,y;f<0?(x=r,y=s):f>1?(x=d,y=l):(x=r+f*c,y=s+f*h);return mt(e-x)+mt(o-y)<=n}(this,o,mt(i),n)||Tt(this,o,n)}return function(t,{mouseX:e,mouseY:o},n,{hitSize:i,useFinalPosition:r}){const s=vt(e,o,t.getProps(["x","y","x2","y2"],r),n);return l(s,i)||Tt(t,{mouseX:e,mouseY:o},r,n)}(this,{mouseX:t,mouseY:e},o,{hitSize:i,useFinalPosition:n})}getCenterPoint(t){return f(this,t)}draw(t){const{x:o,y:n,x2:i,y2:r,cp:s,options:a}=this;if(t.save(),!X(t,a))return t.restore();_(t,a);const d=Math.sqrt(Math.pow(i-o,2)+Math.pow(r-n,2));if(a.curve&&s)return function(t,o,n,i){const{x:r,y:s,x2:a,y2:d,options:l}=o,{startOpts:c,endOpts:h,startAdjust:u,endAdjust:f}=Ot(o),x={x:r,y:s},y={x:a,y:d},p=St(x,n,y,0),b=St(x,n,y,1)-e.PI,g=Mt(x,n,y,u/i),m=Mt(x,n,y,1-f/i),v=new Path2D;t.beginPath(),v.moveTo(g.x,g.y),v.quadraticCurveTo(n.x,n.y,m.x,m.y),t.shadowColor=l.borderShadowColor,t.stroke(v),o.path=v,o.ctx=t,Yt(t,g,{angle:p,adjust:u},c),Yt(t,m,{angle:b,adjust:f},h)}(t,this,s,d),t.restore();const{startOpts:l,endOpts:c,startAdjust:h,endAdjust:u}=Ot(this),f=Math.atan2(r-n,i-o);t.translate(o,n),t.rotate(f),t.beginPath(),t.moveTo(0+h,0),t.lineTo(d-u,0),t.shadowColor=a.borderShadowColor,t.stroke(),It(t,0,h,l),It(t,d,-u,c),t.restore()}get label(){return this.elements&&this.elements[0]}resolveElementProperties(t,o){const n=Q(t,o),{x:i,y:s,x2:a,y2:d}=n,l=function({x:t,y:e,x2:o,y2:n},{top:i,right:r,bottom:s,left:a}){return!(tr&&o>r||es&&n>s)}(n,t.chartArea),c=l?function(t,e,o){const{x:n,y:i}=At(t,e,o),{x:r,y:s}=At(e,t,o);return{x:n,y:i,x2:r,y2:s,width:Math.abs(r-n),height:Math.abs(s-i)}}({x:i,y:s},{x:a,y:d},t.chartArea):{x:i,y:s,x2:a,y2:d,width:Math.abs(a-i),height:Math.abs(d-s)};if(c.centerX=(a+i)/2,c.centerY=(d+s)/2,c.initProperties=A(t,c,o),o.curve){const t={x:c.x,y:c.y},n={x:c.x2,y:c.y2};c.cp=function(t,e,o){const{x:n,y:i,x2:s,y2:a,centerX:d,centerY:l}=t,c=Math.atan2(a-i,s-n),h=M(e.controlPoint,0);return r({x:d+v(o,h.x,!1),y:l+v(o,h.y,!1)},{x:d,y:l},c)}(c,o,e.distanceBetweenPoints(t,n))}const h=function(t,o,n){const i=n.borderWidth,r=e.toPadding(n.padding),s=E(t.ctx,n),a=s.width+r.width+i,d=s.height+r.height+i;return function(t,o,n,i){const{width:r,height:s,padding:a}=n,{xAdjust:d,yAdjust:l}=o,c={x:t.x,y:t.y},h={x:t.x2,y:t.y2},u="auto"===o.rotation?function(t){const{x:o,y:n,x2:i,y2:r}=t,s=Math.atan2(r-n,i-o);return s>e.PI/2?s-e.PI:si&&(e=gt(i,{x:t,y:e},o),t=i),er&&(t=bt(r,{x:t,y:e},o),e=r),{x:t,y:e}}function Tt(t,{mouseX:e,mouseY:o},n,i){const r=t.label;return r.options.display&&r.inRange(e,o,i,n)}function Dt(t,e,o,n){const{labelSize:i,padding:r}=e,s=t.w*n.dx,a=t.h*n.dy,l=s>0&&(i.w/2+r.left-n.x)/s,c=a>0&&(i.h/2+r.top-n.y)/a;return d(Math.max(l,c),0,.25)}function jt(t,e){const{size:o,min:n,max:i,padding:r}=e,s=o/2;return o>i-n?(i+n)/2:(n>=t-r-s&&(t=n+r+s),i<=t+r+s&&(t=i-r-s),t)}function Ot(t){const e=t.options,o=e.arrowHeads&&e.arrowHeads.start,n=e.arrowHeads&&e.arrowHeads.end;return{startOpts:o,endOpts:n,startAdjust:Rt(t,o),endAdjust:Rt(t,n)}}function Rt(t,e){if(!e||!e.display)return 0;const{length:o,width:n}=e,i=t.options.borderWidth/2,r={x:o,y:n+i},s={x:0,y:i};return Math.abs(bt(0,r,s))}function It(t,e,o,n){if(!n||!n.display)return;const{length:i,width:r,fill:s,backgroundColor:a,borderColor:d}=n,l=Math.abs(e-i)+o;t.beginPath(),_(t,n),X(t,n),t.moveTo(l,-r),t.lineTo(e+o,0),t.lineTo(l,r),!0===s?(t.fillStyle=a||d,t.closePath(),t.fill(),t.shadowColor="transparent"):t.shadowColor=n.borderShadowColor,t.stroke()}function Yt(t,{x:e,y:o},{angle:n,adjust:i},r){r&&r.display&&(t.save(),t.translate(e,o),t.rotate(n),It(t,0,-i,r),t.restore())}Ct.defaults={adjustScaleRange:!0,arrowHeads:{display:!1,end:Object.assign({},kt),fill:!1,length:12,start:Object.assign({},kt),width:6},borderDash:[],borderDashOffset:0,borderShadowColor:"transparent",borderWidth:2,curve:!1,controlPoint:{y:"-50%"},display:!0,endValue:void 0,init:void 0,hitTolerance:0,label:{backgroundColor:"rgba(0,0,0,0.8)",backgroundShadowColor:"transparent",borderCapStyle:"butt",borderColor:"black",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderRadius:6,borderShadowColor:"transparent",borderWidth:0,callout:Object.assign({},yt.defaults.callout),color:"#fff",content:null,display:!1,drawTime:void 0,font:{family:void 0,lineHeight:void 0,size:void 0,style:void 0,weight:"bold"},height:void 0,hitTolerance:void 0,opacity:void 0,padding:6,position:"center",rotation:0,shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,textAlign:"center",textStrokeColor:void 0,textStrokeWidth:0,width:void 0,xAdjust:0,yAdjust:0,z:void 0},scaleID:void 0,shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,value:void 0,xMax:void 0,xMin:void 0,xScaleID:void 0,yMax:void 0,yMin:void 0,yScaleID:void 0,z:0},Ct.descriptors={arrowHeads:{start:{_fallback:!0},end:{_fallback:!0},_fallback:!0}},Ct.defaultRoutes={borderColor:"color"};class Xt extends t.Element{inRange(t,o,n,i){const s=this.options.rotation,d=(this.options.borderWidth+this.options.hitTolerance)/2;if("x"!==n&&"y"!==n)return function(t,o,n,i){const{width:r,height:s,centerX:a,centerY:d}=o,l=r/2,c=s/2;if(l<=0||c<=0)return!1;const h=e.toRadians(n||0),u=Math.cos(h),f=Math.sin(h),x=Math.pow(u*(t.x-a)+f*(t.y-d),2),y=Math.pow(f*(t.x-a)-u*(t.y-d),2);return x/Math.pow(l+i,2)+y/Math.pow(c+i,2)<=1.0001}({x:t,y:o},this.getProps(["width","height","centerX","centerY"],i),s,d);const{x:l,y:c,x2:h,y2:u}=this.getProps(["x","y","x2","y2"],i),f="y"===n?{start:c,end:u}:{start:l,end:h},x=r({x:t,y:o},this.getCenterPoint(i),e.toRadians(-s));return x[n]>=f.start-d-a&&x[n]<=f.end+d+a}getCenterPoint(t){return f(this,t)}draw(t){const{width:o,height:n,centerX:i,centerY:r,options:s}=this;t.save(),Y(t,this.getCenterPoint(),s.rotation),_(t,this.options),t.beginPath(),t.fillStyle=s.backgroundColor;const a=X(t,s);t.ellipse(i,r,n/2,o/2,e.PI/2,0,2*e.PI),t.fill(),a&&(t.shadowColor=s.borderShadowColor,t.stroke()),t.restore()}get label(){return this.elements&&this.elements[0]}resolveElementProperties(t,e){return G(t,e)}}Xt.id="ellipseAnnotation",Xt.defaults={adjustScaleRange:!0,backgroundShadowColor:"transparent",borderDash:[],borderDashOffset:0,borderShadowColor:"transparent",borderWidth:1,display:!0,hitTolerance:0,init:void 0,label:Object.assign({},ft.defaults.label),rotation:0,shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,xMax:void 0,xMin:void 0,xScaleID:void 0,yMax:void 0,yMin:void 0,yScaleID:void 0,z:0},Xt.defaultRoutes={borderColor:"color",backgroundColor:"color"},Xt.descriptors={label:{_fallback:!0}};class _t extends t.Element{inRange(t,e,o,n){const{x:i,y:r,x2:s,y2:a,width:d}=this.getProps(["x","y","x2","y2","width"],n),c=(this.options.borderWidth+this.options.hitTolerance)/2;if("x"!==o&&"y"!==o)return function(t,e,o,n){return!(!t||!e||o<=0)&&Math.pow(t.x-e.x,2)+Math.pow(t.y-e.y,2)<=Math.pow(o+n,2)}({x:t,y:e},this.getCenterPoint(n),d/2,c);return l("y"===o?{start:r,end:a,value:e}:{start:i,end:s,value:t},c)}getCenterPoint(t){return f(this,t)}draw(t){const e=this.options,o=e.borderWidth;if(e.radius<.1)return;t.save(),t.fillStyle=e.backgroundColor,_(t,e);const n=X(t,e);F(t,this,this.centerX,this.centerY),n&&!I(e.pointStyle)&&(t.shadowColor=e.borderShadowColor,t.stroke()),t.restore(),e.borderWidth=o}resolveElementProperties(t,e){const o=q(t,e);return o.initProperties=A(t,o,e),o}}_t.id="pointAnnotation",_t.defaults={adjustScaleRange:!0,backgroundShadowColor:"transparent",borderDash:[],borderDashOffset:0,borderShadowColor:"transparent",borderWidth:1,display:!0,hitTolerance:0,init:void 0,pointStyle:"circle",radius:10,rotation:0,shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,xAdjust:0,xMax:void 0,xMin:void 0,xScaleID:void 0,xValue:void 0,yAdjust:0,yMax:void 0,yMin:void 0,yScaleID:void 0,yValue:void 0,z:0},_t.defaultRoutes={borderColor:"color",backgroundColor:"color"};class Et extends t.Element{inRange(t,o,n,i){if("x"!==n&&"y"!==n)return this.options.radius>=.1&&this.elements.length>1&&function(t,e,o,n){let i=!1,r=t[t.length-1].getProps(["bX","bY"],n);for(const s of t){const t=s.getProps(["bX","bY"],n);t.bY>o!=r.bY>o&&e<(r.bX-t.bX)*(o-t.bY)/(r.bY-t.bY)+t.bX&&(i=!i),r=t}return i}(this.elements,t,o,i);const s=r({x:t,y:o},this.getCenterPoint(i),e.toRadians(-this.options.rotation)),a=this.elements.map((t=>"y"===n?t.bY:t.bX)),d=Math.min(...a),l=Math.max(...a);return s[n]>=d&&s[n]<=l}getCenterPoint(t){return f(this,t)}draw(t){const{elements:e,options:o}=this;t.save(),t.beginPath(),t.fillStyle=o.backgroundColor,_(t,o);const n=X(t,o);let i=!0;for(const o of e)i?(t.moveTo(o.x,o.y),i=!1):t.lineTo(o.x,o.y);t.closePath(),t.fill(),n&&(t.shadowColor=o.borderShadowColor,t.stroke()),t.restore()}resolveElementProperties(t,o){const n=q(t,o),{sides:i,rotation:r}=o,s=[],a=2*e.PI/i;let d=r*e.RAD_PER_DEG;for(let e=0;e{t.defaults.describe(`elements.${zt[e].id}`,{_fallback:"plugins.annotation.common"})}));const Ft={update:Object.assign},Ht=ot.concat(st),Nt=(t,o)=>e.isObject(o)?Qt(t,o):t,Lt=t=>"color"===t||"font"===t;function Vt(t="line"){return zt[t]?t:(console.warn(`Unknown annotation type: '${t}', defaulting to 'line'`),"line")}function Bt(o,n,i,r){const s=function(e,o,n){if("reset"===n||"none"===n||"resize"===n)return Ft;return new t.Animations(e,o)}(o,i.animations,r),a=n.annotations,d=function(t,e){const o=e.length,n=t.length;if(no&&t.splice(o,n-o);return t}(n.elements,a);for(let t=0;tNt(t,r))):n[i]=Nt(s,r)}return n}function Gt(t,e,o,n){return e.$context||(e.$context=Object.assign(Object.create(t.getContext()),{element:e,get elements(){return o.filter((t=>t&&t.options))},id:n.id,type:"annotation"}))}const Kt=new Map,Zt=t=>"doughnutLabel"!==t.type,te=ot.concat(st);var ee={id:"annotation",version:"3.1.0",beforeRegister(){!function(t,e,o,n=!0){const i=o.split(".");let r=0;for(const a of e.split(".")){const d=i[r++];if(parseInt(a,10){const o=r[t];e.isObject(o)&&(o.id=t,i.push(o))})):e.isArray(r)&&i.push(...r),function(t,e){for(const o of t)ct(o,e)}(i.filter(Zt),t.scales)},afterDataLimits(t,e){const o=Kt.get(t);dt(t,e.scale,o.annotations.filter(Zt).filter((t=>t.display&&t.adjustScaleRange)))},afterUpdate(t,o,n){const i=Kt.get(t);!function(t,o,n){o.listened=T(n,ot,o.listeners),o.moveListened=!1,et.forEach((t=>{e.isFunction(n[t])&&(o.moveListened=!0)})),o.listened&&o.moveListened||o.annotations.forEach((t=>{!o.listened&&e.isFunction(t.click)&&(o.listened=!0),o.moveListened||et.forEach((n=>{e.isFunction(t[n])&&(o.listened=!0,o.moveListened=!0)}))}))}(0,i,n),Bt(t,i,n,o.mode),i.visibleElements=i.elements.filter((t=>!t.skip&&t.options.display)),function(t,o,n){const i=o.visibleElements;o.hooked=T(n,st,o.hooks),o.hooked||i.forEach((t=>{o.hooked||st.forEach((n=>{e.isFunction(t.options[n])&&(o.hooked=!0)}))}))}(0,i,n)},beforeDatasetsDraw(t,e,o){oe(t,"beforeDatasetsDraw",o.clip)},afterDatasetsDraw(t,e,o){oe(t,"afterDatasetsDraw",o.clip)},beforeDatasetDraw(t,e,o){oe(t,e.index,o.clip)},beforeDraw(t,e,o){oe(t,"beforeDraw",o.clip)},afterDraw(t,e,o){oe(t,"afterDraw",o.clip)},beforeEvent(t,e,o){nt(Kt.get(t),e.event,o)&&(e.changed=!0)},afterDestroy(t){Kt.delete(t)},getAnnotations(t){const e=Kt.get(t);return e?e.elements:[]},_getAnnotationElementsAtEventForMode:(t,e,o)=>n(t,e,o),defaults:{animations:{numbers:{properties:["x","y","x2","y2","width","height","centerX","centerY","pointX","pointY","radius"],type:"number"},colors:{properties:["backgroundColor","borderColor"],type:"color"}},clip:!0,interaction:{mode:void 0,axis:void 0,intersect:void 0},common:{drawTime:"afterDatasetsDraw",init:!1,label:{}}},descriptors:{_indexable:!1,_scriptable:t=>!te.includes(t)&&"init"!==t,annotations:{_allKeys:!1,_fallback:(t,e)=>`elements.${zt[Vt(e.type)].id}`},interaction:{_fallback:!0},common:{label:{_indexable:Lt,_fallback:!0},_indexable:Lt}},additionalOptionScopes:[""]};function oe(t,o,n){const{ctx:i,chartArea:r}=t,s=Kt.get(t);n&&e.clipArea(i,r);const a=function(t,e){const o=[];for(const n of t)if(n.options.drawTime===e&&o.push({element:n,main:!0}),n.elements&&n.elements.length)for(const t of n.elements)t.options.display&&t.options.drawTime===e&&o.push({element:t});return o}(s.visibleElements,o).sort(((t,e)=>t.element.options.z-e.element.options.z));for(const t of a)ne(i,r,s,t);n&&e.unclipArea(i)}function ne(t,e,o,n){const i=n.element;n.main?(at(o,i,"beforeDraw"),i.draw(t,e),at(o,i,"afterDraw")):i.draw(t,e)}return t.Chart.register(ee),ee})); diff --git a/internal/dashboard/static/cytoscape.min.js b/internal/dashboard/static/cytoscape.min.js deleted file mode 100644 index f36c15d..0000000 --- a/internal/dashboard/static/cytoscape.min.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright (c) 2016-2024, The Cytoscape Consortium. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the “Software”), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies - * of the Software, and to permit persons to whom the Software is furnished to do - * so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).cytoscape=t()}(this,(function(){"use strict";function e(t){return(e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(t)}function t(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function n(e,t){for(var n=0;ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var a,s=!0,l=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return s=e.done,e},e:function(e){l=!0,a=e},f:function(){try{s||null==n.return||n.return()}finally{if(l)throw a}}}}var u="undefined"==typeof window?null:window,c=u?u.navigator:null;u&&u.document;var d=e(""),h=e({}),p=e((function(){})),f="undefined"==typeof HTMLElement?"undefined":e(HTMLElement),g=function(e){return e&&e.instanceString&&y(e.instanceString)?e.instanceString():null},v=function(t){return null!=t&&e(t)==d},y=function(t){return null!=t&&e(t)===p},m=function(e){return!E(e)&&(Array.isArray?Array.isArray(e):null!=e&&e instanceof Array)},b=function(t){return null!=t&&e(t)===h&&!m(t)&&t.constructor===Object},x=function(t){return null!=t&&e(t)===e(1)&&!isNaN(t)},w=function(e){return"undefined"===f?void 0:null!=e&&e instanceof HTMLElement},E=function(e){return k(e)||C(e)},k=function(e){return"collection"===g(e)&&e._private.single},C=function(e){return"collection"===g(e)&&!e._private.single},S=function(e){return"core"===g(e)},P=function(e){return"stylesheet"===g(e)},D=function(e){return null==e||!(""!==e&&!e.match(/^\s+$/))},T=function(t){return function(t){return null!=t&&e(t)===h}(t)&&y(t.then)},_=function(e,t){t||(t=function(){if(1===arguments.length)return arguments[0];if(0===arguments.length)return"undefined";for(var e=[],t=0;tt?1:0},L=null!=Object.assign?Object.assign.bind(Object):function(e){for(var t=arguments,n=1;n255)return;t.push(Math.floor(a))}var o=r[1]||r[2]||r[3],s=r[1]&&r[2]&&r[3];if(o&&!s)return;var l=n[4];if(void 0!==l){if((l=parseFloat(l))<0||l>1)return;t.push(l)}}return t}(e)||function(e){var t,n,r,i,a,o,s,l;function u(e,t,n){return n<0&&(n+=1),n>1&&(n-=1),n<1/6?e+6*(t-e)*n:n<.5?t:n<2/3?e+(t-e)*(2/3-n)*6:e}var c=new RegExp("^hsl[a]?\\(((?:[-+]?(?:(?:\\d+|\\d*\\.\\d+)(?:[Ee][+-]?\\d+)?)))\\s*,\\s*((?:[-+]?(?:(?:\\d+|\\d*\\.\\d+)(?:[Ee][+-]?\\d+)?))[%])\\s*,\\s*((?:[-+]?(?:(?:\\d+|\\d*\\.\\d+)(?:[Ee][+-]?\\d+)?))[%])(?:\\s*,\\s*((?:[-+]?(?:(?:\\d+|\\d*\\.\\d+)(?:[Ee][+-]?\\d+)?))))?\\)$").exec(e);if(c){if((n=parseInt(c[1]))<0?n=(360- -1*n%360)%360:n>360&&(n%=360),n/=360,(r=parseFloat(c[2]))<0||r>100)return;if(r/=100,(i=parseFloat(c[3]))<0||i>100)return;if(i/=100,void 0!==(a=c[4])&&((a=parseFloat(a))<0||a>1))return;if(0===r)o=s=l=Math.round(255*i);else{var d=i<.5?i*(1+r):i+r-i*r,h=2*i-d;o=Math.round(255*u(h,d,n+1/3)),s=Math.round(255*u(h,d,n)),l=Math.round(255*u(h,d,n-1/3))}t=[o,s,l,a]}return t}(e)},R={transparent:[0,0,0,0],aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],grey:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]},V=function(e){for(var t=e.map,n=e.keys,r=n.length,i=0;i=t||n<0||d&&e-u>=a}function v(){var e=H();if(g(e))return y(e);s=setTimeout(v,function(e){var n=t-(e-l);return d?ge(n,a-(e-u)):n}(e))}function y(e){return s=void 0,h&&r?p(e):(r=i=void 0,o)}function m(){var e=H(),n=g(e);if(r=arguments,i=this,l=e,n){if(void 0===s)return f(l);if(d)return clearTimeout(s),s=setTimeout(v,t),p(l)}return void 0===s&&(s=setTimeout(v,t)),o}return t=pe(t)||0,j(n)&&(c=!!n.leading,a=(d="maxWait"in n)?fe(pe(n.maxWait)||0,t):a,h="trailing"in n?!!n.trailing:h),m.cancel=function(){void 0!==s&&clearTimeout(s),u=0,r=l=i=s=void 0},m.flush=function(){return void 0===s?o:y(H())},m},ye=u?u.performance:null,me=ye&&ye.now?function(){return ye.now()}:function(){return Date.now()},be=function(){if(u){if(u.requestAnimationFrame)return function(e){u.requestAnimationFrame(e)};if(u.mozRequestAnimationFrame)return function(e){u.mozRequestAnimationFrame(e)};if(u.webkitRequestAnimationFrame)return function(e){u.webkitRequestAnimationFrame(e)};if(u.msRequestAnimationFrame)return function(e){u.msRequestAnimationFrame(e)}}return function(e){e&&setTimeout((function(){e(me())}),1e3/60)}}(),xe=function(e){return be(e)},we=me,Ee=65599,ke=function(e){for(var t,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:9261,r=n;!(t=e.next()).done;)r=r*Ee+t.value|0;return r},Ce=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:9261;return t*Ee+e|0},Se=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:5381;return(t<<5)+t+e|0},Pe=function(e){return 2097152*e[0]+e[1]},De=function(e,t){return[Ce(e[0],t[0]),Se(e[1],t[1])]},Te=function(e,t){var n={value:0,done:!1},r=0,i=e.length;return ke({next:function(){return r=0&&(e[r]!==t||(e.splice(r,1),!n));r--);},Ge=function(e){e.splice(0,e.length)},Ue=function(e,t,n){return n&&(t=N(n,t)),e[t]},Ze=function(e,t,n,r){n&&(t=N(n,t)),e[t]=r},$e="undefined"!=typeof Map?Map:function(){function e(){t(this,e),this._obj={}}return r(e,[{key:"set",value:function(e,t){return this._obj[e]=t,this}},{key:"delete",value:function(e){return this._obj[e]=void 0,this}},{key:"clear",value:function(){this._obj={}}},{key:"has",value:function(e){return void 0!==this._obj[e]}},{key:"get",value:function(e){return this._obj[e]}}]),e}(),Qe=function(){function e(n){if(t(this,e),this._obj=Object.create(null),this.size=0,null!=n){var r;r=null!=n.instanceString&&n.instanceString()===this.instanceString()?n.toArray():n;for(var i=0;i2&&void 0!==arguments[2])||arguments[2];if(void 0!==e&&void 0!==t&&S(e)){var r=t.group;if(null==r&&(r=t.data&&null!=t.data.source&&null!=t.data.target?"edges":"nodes"),"nodes"===r||"edges"===r){this.length=1,this[0]=this;var i=this._private={cy:e,single:!0,data:t.data||{},position:t.position||{x:0,y:0},autoWidth:void 0,autoHeight:void 0,autoPadding:void 0,compoundBoundsClean:!1,listeners:[],group:r,style:{},rstyle:{},styleCxts:[],styleKeys:{},removed:!0,selected:!!t.selected,selectable:void 0===t.selectable||!!t.selectable,locked:!!t.locked,grabbed:!1,grabbable:void 0===t.grabbable||!!t.grabbable,pannable:void 0===t.pannable?"edges"===r:!!t.pannable,active:!1,classes:new Je,animation:{current:[],queue:[]},rscratch:{},scratch:t.scratch||{},edges:[],children:[],parent:t.parent&&t.parent.isNode()?t.parent:null,traversalCache:{},backgrounding:!1,bbCache:null,bbCacheShift:{x:0,y:0},bodyBounds:null,overlayBounds:null,labelBounds:{all:null,source:null,target:null,main:null},arrowBounds:{source:null,target:null,"mid-source":null,"mid-target":null}};if(null==i.position.x&&(i.position.x=0),null==i.position.y&&(i.position.y=0),t.renderedPosition){var a=t.renderedPosition,o=e.pan(),s=e.zoom();i.position={x:(a.x-o.x)/s,y:(a.y-o.y)/s}}var l=[];m(t.classes)?l=t.classes:v(t.classes)&&(l=t.classes.split(/\s+/));for(var u=0,c=l.length;ut?1:0},u=function(e,t,i,a,o){var s;if(null==i&&(i=0),null==o&&(o=n),i<0)throw new Error("lo must be non-negative");for(null==a&&(a=e.length);in;0<=n?t++:t--)u.push(t);return u}.apply(this).reverse()).length;ag;0<=g?++h:--h)v.push(a(e,r));return v},f=function(e,t,r,i){var a,o,s;for(null==i&&(i=n),a=e[r];r>t&&i(a,o=e[s=r-1>>1])<0;)e[r]=o,r=s;return e[r]=a},g=function(e,t,r){var i,a,o,s,l;for(null==r&&(r=n),a=e.length,l=t,o=e[t],i=2*t+1;i0;){var k=m.pop(),C=g(k),S=k.id();if(d[S]=C,C!==1/0)for(var P=k.neighborhood().intersect(p),D=0;D0)for(n.unshift(t);c[i];){var a=c[i];n.unshift(a.edge),n.unshift(a.node),i=(r=a.node).id()}return o.spawn(n)}}}},ot={kruskal:function(e){e=e||function(e){return 1};for(var t=this.byGroup(),n=t.nodes,r=t.edges,i=n.length,a=new Array(i),o=n,s=function(e){for(var t=0;t0;){if(l=g.pop(),u=l.id(),v.delete(u),w++,u===d){for(var E=[],k=i,C=d,S=m[C];E.unshift(k),null!=S&&E.unshift(S),null!=(k=y[C]);)S=m[C=k.id()];return{found:!0,distance:h[u],path:this.spawn(E),steps:w}}f[u]=!0;for(var P=l._private.edges,D=0;DD&&(p[P]=D,m[P]=S,b[P]=w),!i){var T=S*u+C;!i&&p[T]>D&&(p[T]=D,m[T]=C,b[T]=w)}}}for(var _=0;_1&&void 0!==arguments[1]?arguments[1]:a,r=b(e),i=[],o=r;;){if(null==o)return t.spawn();var l=m(o),u=l.edge,c=l.pred;if(i.unshift(o[0]),o.same(n)&&i.length>0)break;null!=u&&i.unshift(u),o=c}return s.spawn(i)},hasNegativeWeightCycle:f,negativeWeightCycles:g}}},pt=Math.sqrt(2),ft=function(e,t,n){0===n.length&&Ve("Karger-Stein must be run on a connected (sub)graph");for(var r=n[e],i=r[1],a=r[2],o=t[i],s=t[a],l=n,u=l.length-1;u>=0;u--){var c=l[u],d=c[1],h=c[2];(t[d]===o&&t[h]===s||t[d]===s&&t[h]===o)&&l.splice(u,1)}for(var p=0;pr;){var i=Math.floor(Math.random()*t.length);t=ft(i,e,t),n--}return t},vt={kargerStein:function(){var e=this,t=this.byGroup(),n=t.nodes,r=t.edges;r.unmergeBy((function(e){return e.isLoop()}));var i=n.length,a=r.length,o=Math.ceil(Math.pow(Math.log(i)/Math.LN2,2)),s=Math.floor(i/pt);if(!(i<2)){for(var l=[],u=0;u0?1:e<0?-1:0},kt=function(e,t){return Math.sqrt(Ct(e,t))},Ct=function(e,t){var n=t.x-e.x,r=t.y-e.y;return n*n+r*r},St=function(e){for(var t=e.length,n=0,r=0;r=e.x1&&e.y2>=e.y1)return{x1:e.x1,y1:e.y1,x2:e.x2,y2:e.y2,w:e.x2-e.x1,h:e.y2-e.y1};if(null!=e.w&&null!=e.h&&e.w>=0&&e.h>=0)return{x1:e.x1,y1:e.y1,x2:e.x1+e.w,y2:e.y1+e.h,w:e.w,h:e.h}}},Mt=function(e,t){e.x1=Math.min(e.x1,t.x1),e.x2=Math.max(e.x2,t.x2),e.w=e.x2-e.x1,e.y1=Math.min(e.y1,t.y1),e.y2=Math.max(e.y2,t.y2),e.h=e.y2-e.y1},Bt=function(e,t,n){e.x1=Math.min(e.x1,t),e.x2=Math.max(e.x2,t),e.w=e.x2-e.x1,e.y1=Math.min(e.y1,n),e.y2=Math.max(e.y2,n),e.h=e.y2-e.y1},Nt=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0;return e.x1-=t,e.x2+=t,e.y1-=t,e.y2+=t,e.w=e.x2-e.x1,e.h=e.y2-e.y1,e},zt=function(e){var t,n,r,i,o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[0];if(1===o.length)t=n=r=i=o[0];else if(2===o.length)t=r=o[0],i=n=o[1];else if(4===o.length){var s=a(o,4);t=s[0],n=s[1],r=s[2],i=s[3]}return e.x1-=i,e.x2+=n,e.y1-=t,e.y2+=r,e.w=e.x2-e.x1,e.h=e.y2-e.y1,e},It=function(e,t){e.x1=t.x1,e.y1=t.y1,e.x2=t.x2,e.y2=t.y2,e.w=e.x2-e.x1,e.h=e.y2-e.y1},At=function(e,t){return!(e.x1>t.x2)&&(!(t.x1>e.x2)&&(!(e.x2t.y2)&&!(t.y1>e.y2)))))))},Lt=function(e,t,n){return e.x1<=t&&t<=e.x2&&e.y1<=n&&n<=e.y2},Ot=function(e,t){return Lt(e,t.x1,t.y1)&&Lt(e,t.x2,t.y2)},Rt=function(e,t,n,r,i,a,o){var s,l,u=arguments.length>7&&void 0!==arguments[7]?arguments[7]:"auto",c="auto"===u?nn(i,a):u,d=i/2,h=a/2,p=(c=Math.min(c,d,h))!==d,f=c!==h;if(p){var g=n-d+c-o,v=r-h-o,y=n+d-c+o,m=v;if((s=Zt(e,t,n,r,g,v,y,m,!1)).length>0)return s}if(f){var b=n+d+o,x=r-h+c-o,w=b,E=r+h-c+o;if((s=Zt(e,t,n,r,b,x,w,E,!1)).length>0)return s}if(p){var k=n-d+c-o,C=r+h+o,S=n+d-c+o,P=C;if((s=Zt(e,t,n,r,k,C,S,P,!1)).length>0)return s}if(f){var D=n-d-o,T=r-h+c-o,_=D,M=r+h-c+o;if((s=Zt(e,t,n,r,D,T,_,M,!1)).length>0)return s}var B=n-d+c,N=r-h+c;if((l=Gt(e,t,n,r,B,N,c+o)).length>0&&l[0]<=B&&l[1]<=N)return[l[0],l[1]];var z=n+d-c,I=r-h+c;if((l=Gt(e,t,n,r,z,I,c+o)).length>0&&l[0]>=z&&l[1]<=I)return[l[0],l[1]];var A=n+d-c,L=r+h-c;if((l=Gt(e,t,n,r,A,L,c+o)).length>0&&l[0]>=A&&l[1]>=L)return[l[0],l[1]];var O=n-d+c,R=r+h-c;return(l=Gt(e,t,n,r,O,R,c+o)).length>0&&l[0]<=O&&l[1]>=R?[l[0],l[1]]:[]},Vt=function(e,t,n,r,i,a,o){var s=o,l=Math.min(n,i),u=Math.max(n,i),c=Math.min(r,a),d=Math.max(r,a);return l-s<=e&&e<=u+s&&c-s<=t&&t<=d+s},Ft=function(e,t,n,r,i,a,o,s,l){var u=Math.min(n,o,i)-l,c=Math.max(n,o,i)+l,d=Math.min(r,s,a)-l,h=Math.max(r,s,a)+l;return!(ec||th)},jt=function(e,t,n,r,i,a,o,s){var l=[];!function(e,t,n,r,i){var a,o,s,l,u,c,d,h;0===e&&(e=1e-5),s=-27*(r/=e)+(t/=e)*(9*(n/=e)-t*t*2),a=(o=(3*n-t*t)/9)*o*o+(s/=54)*s,i[1]=0,d=t/3,a>0?(u=(u=s+Math.sqrt(a))<0?-Math.pow(-u,1/3):Math.pow(u,1/3),c=(c=s-Math.sqrt(a))<0?-Math.pow(-c,1/3):Math.pow(c,1/3),i[0]=-d+u+c,d+=(u+c)/2,i[4]=i[2]=-d,d=Math.sqrt(3)*(-c+u)/2,i[3]=d,i[5]=-d):(i[5]=i[3]=0,0===a?(h=s<0?-Math.pow(-s,1/3):Math.pow(s,1/3),i[0]=2*h-d,i[4]=i[2]=-(h+d)):(l=(o=-o)*o*o,l=Math.acos(s/Math.sqrt(l)),h=2*Math.sqrt(o),i[0]=-d+h*Math.cos(l/3),i[2]=-d+h*Math.cos((l+2*Math.PI)/3),i[4]=-d+h*Math.cos((l+4*Math.PI)/3)))}(1*n*n-4*n*i+2*n*o+4*i*i-4*i*o+o*o+r*r-4*r*a+2*r*s+4*a*a-4*a*s+s*s,9*n*i-3*n*n-3*n*o-6*i*i+3*i*o+9*r*a-3*r*r-3*r*s-6*a*a+3*a*s,3*n*n-6*n*i+n*o-n*e+2*i*i+2*i*e-o*e+3*r*r-6*r*a+r*s-r*t+2*a*a+2*a*t-s*t,1*n*i-n*n+n*e-i*e+r*a-r*r+r*t-a*t,l);for(var u=[],c=0;c<6;c+=2)Math.abs(l[c+1])<1e-7&&l[c]>=0&&l[c]<=1&&u.push(l[c]);u.push(1),u.push(0);for(var d,h,p,f=-1,g=0;g=0?pl?(e-i)*(e-i)+(t-a)*(t-a):u-d},Yt=function(e,t,n){for(var r,i,a,o,s=0,l=0;l=e&&e>=a||r<=e&&e<=a))continue;(e-r)/(a-r)*(o-i)+i>t&&s++}return s%2!=0},Xt=function(e,t,n,r,i,a,o,s,l){var u,c=new Array(n.length);null!=s[0]?(u=Math.atan(s[1]/s[0]),s[0]<0?u+=Math.PI/2:u=-u-Math.PI/2):u=s;for(var d,h=Math.cos(-u),p=Math.sin(-u),f=0;f0){var g=Ht(c,-l);d=Wt(g)}else d=c;return Yt(e,t,d)},Wt=function(e){for(var t,n,r,i,a,o,s,l,u=new Array(e.length/2),c=0;c=0&&f<=1&&v.push(f),g>=0&&g<=1&&v.push(g),0===v.length)return[];var y=v[0]*s[0]+e,m=v[0]*s[1]+t;return v.length>1?v[0]==v[1]?[y,m]:[y,m,v[1]*s[0]+e,v[1]*s[1]+t]:[y,m]},Ut=function(e,t,n){return t<=e&&e<=n||n<=e&&e<=t?e:e<=t&&t<=n||n<=t&&t<=e?t:n},Zt=function(e,t,n,r,i,a,o,s,l){var u=e-i,c=n-e,d=o-i,h=t-a,p=r-t,f=s-a,g=d*h-f*u,v=c*h-p*u,y=f*c-d*p;if(0!==y){var m=g/y,b=v/y;return-.001<=m&&m<=1.001&&-.001<=b&&b<=1.001||l?[e+m*c,t+m*p]:[]}return 0===g||0===v?Ut(e,n,o)===o?[o,s]:Ut(e,n,i)===i?[i,a]:Ut(i,o,n)===n?[n,r]:[]:[]},$t=function(e,t,n,r,i,a,o,s){var l,u,c,d,h,p,f=[],g=new Array(n.length),v=!0;if(null==a&&(v=!1),v){for(var y=0;y0){var m=Ht(g,-s);u=Wt(m)}else u=g}else u=n;for(var b=0;bu&&(u=t)},d=function(e){return l[e]},h=0;h0?b.edgesTo(m)[0]:m.edgesTo(b)[0];var w=r(x);m=m.id(),h[m]>h[v]+w&&(h[m]=h[v]+w,p.nodes.indexOf(m)<0?p.push(m):p.updateItem(m),u[m]=0,l[m]=[]),h[m]==h[v]+w&&(u[m]=u[m]+u[v],l[m].push(v))}else for(var E=0;E0;){for(var P=n.pop(),D=0;D0&&o.push(n[s]);0!==o.length&&i.push(r.collection(o))}return i}(c,l,t,r);return b=function(e){for(var t=0;t5&&void 0!==arguments[5]?arguments[5]:Cn,o=r,s=0;s=2?Mn(e,t,n,0,Dn,Tn):Mn(e,t,n,0,Pn)},squaredEuclidean:function(e,t,n){return Mn(e,t,n,0,Dn)},manhattan:function(e,t,n){return Mn(e,t,n,0,Pn)},max:function(e,t,n){return Mn(e,t,n,-1/0,_n)}};function Nn(e,t,n,r,i,a){var o;return o=y(e)?e:Bn[e]||Bn.euclidean,0===t&&y(e)?o(i,a):o(t,n,r,i,a)}Bn["squared-euclidean"]=Bn.squaredEuclidean,Bn.squaredeuclidean=Bn.squaredEuclidean;var zn=He({k:2,m:2,sensitivityThreshold:1e-4,distance:"euclidean",maxIterations:10,attributes:[],testMode:!1,testCentroids:null}),In=function(e){return zn(e)},An=function(e,t,n,r,i){var a="kMedoids"!==i?function(e){return n[e]}:function(e){return r[e](n)},o=n,s=t;return Nn(e,r.length,a,(function(e){return r[e](t)}),o,s)},Ln=function(e,t,n){for(var r=n.length,i=new Array(r),a=new Array(r),o=new Array(t),s=null,l=0;ln)return!1}return!0},jn=function(e,t,n){for(var r=0;ri&&(i=t[l][u],a=u);o[a].push(e[l])}for(var c=0;c=i.threshold||"dendrogram"===i.mode&&1===e.length)return!1;var p,f=t[o],g=t[r[o]];p="dendrogram"===i.mode?{left:f,right:g,key:f.key}:{value:f.value.concat(g.value),key:f.key},e[f.index]=p,e.splice(g.index,1),t[f.key]=p;for(var v=0;vn[g.key][y.key]&&(a=n[g.key][y.key])):"max"===i.linkage?(a=n[f.key][y.key],n[f.key][y.key]1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length,r=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],i=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],a=!(arguments.length>5&&void 0!==arguments[5])||arguments[5];r?e=e.slice(t,n):(n0&&e.splice(0,t));for(var o=0,s=e.length-1;s>=0;s--){var l=e[s];a?isFinite(l)||(e[s]=-1/0,o++):e.splice(s,1)}i&&e.sort((function(e,t){return e-t}));var u=e.length,c=Math.floor(u/2);return u%2!=0?e[c+1+o]:(e[c-1+o]+e[c+o])/2}(e):"mean"===t?function(e){for(var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length,r=0,i=0,a=t;a1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length,r=1/0,i=t;i1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length,r=-1/0,i=t;io&&(a=l,o=t[i*e+l])}a>0&&r.push(a)}for(var u=0;u=D?(T=D,D=M,_=B):M>T&&(T=M);for(var N=0;N0?1:0;C[k%u.minIterations*t+R]=V,O+=V}if(O>0&&(k>=u.minIterations-1||k==u.maxIterations-1)){for(var F=0,j=0;j0&&r.push(i);return r}(t,a,o),X=function(e,t,n){for(var r=rr(e,t,n),i=0;il&&(s=u,l=c)}n[i]=a[s]}return r=rr(e,t,n)}(t,r,Y),W={},H=0;H1)}}));var l=Object.keys(t).filter((function(e){return t[e].cutVertex})).map((function(t){return e.getElementById(t)}));return{cut:e.spawn(l),components:i}},lr=function(){var e=this,t={},n=0,r=[],i=[],a=e.spawn(e);return e.forEach((function(o){if(o.isNode()){var s=o.id();s in t||function o(s){if(i.push(s),t[s]={index:n,low:n++,explored:!1},e.getElementById(s).connectedEdges().intersection(e).forEach((function(e){var n=e.target().id();n!==s&&(n in t||o(n),t[n].explored||(t[s].low=Math.min(t[s].low,t[n].low)))})),t[s].index===t[s].low){for(var l=e.spawn();;){var u=i.pop();if(l.merge(e.getElementById(u)),t[u].low=t[s].index,t[u].explored=!0,u===s)break}var c=l.edgesWith(l),d=l.merge(c);r.push(d),a=a.difference(d)}}(s)}})),{cut:a,components:r}},ur={};[nt,at,ot,lt,ct,ht,vt,sn,un,dn,pn,kn,Kn,Jn,ar,{hierholzer:function(e){if(!b(e)){var t=arguments;e={root:t[0],directed:t[1]}}var n,r,i,a=or(e),o=a.root,s=a.directed,l=this,u=!1;o&&(i=v(o)?this.filter(o)[0].id():o[0].id());var c={},d={};s?l.forEach((function(e){var t=e.id();if(e.isNode()){var i=e.indegree(!0),a=e.outdegree(!0),o=i-a,s=a-i;1==o?n?u=!0:n=t:1==s?r?u=!0:r=t:(s>1||o>1)&&(u=!0),c[t]=[],e.outgoers().forEach((function(e){e.isEdge()&&c[t].push(e.id())}))}else d[t]=[void 0,e.target().id()]})):l.forEach((function(e){var t=e.id();e.isNode()?(e.degree(!0)%2&&(n?r?u=!0:r=t:n=t),c[t]=[],e.connectedEdges().forEach((function(e){return c[t].push(e.id())}))):d[t]=[e.source().id(),e.target().id()]}));var h={found:!1,trail:void 0};if(u)return h;if(r&&n)if(s){if(i&&r!=i)return h;i=r}else{if(i&&r!=i&&n!=i)return h;i||(i=r)}else i||(i=l[0].id());var p=function(e){for(var t,n,r,i=e,a=[e];c[i].length;)t=c[i].shift(),n=d[t][0],i!=(r=d[t][1])?(c[r]=c[r].filter((function(e){return e!=t})),i=r):s||i==n||(c[n]=c[n].filter((function(e){return e!=t})),i=n),a.unshift(t),a.unshift(i);return a},f=[],g=[];for(g=p(i);1!=g.length;)0==c[g[0]].length?(f.unshift(l.getElementById(g.shift())),f.unshift(l.getElementById(g.shift()))):g=p(g.shift()).concat(g);for(var y in f.unshift(l.getElementById(g.shift())),c)if(c[y].length)return h;return h.found=!0,h.trail=this.spawn(f,!0),h}},{hopcroftTarjanBiconnected:sr,htbc:sr,htb:sr,hopcroftTarjanBiconnectedComponents:sr},{tarjanStronglyConnected:lr,tsc:lr,tscc:lr,tarjanStronglyConnectedComponents:lr}].forEach((function(e){L(ur,e)})); -/*! - Embeddable Minimum Strictly-Compliant Promises/A+ 1.1.1 Thenable - Copyright (c) 2013-2014 Ralf S. Engelschall (http://engelschall.com) - Licensed under The MIT License (http://opensource.org/licenses/MIT) - */ -var cr=function e(t){if(!(this instanceof e))return new e(t);this.id="Thenable/1.0.7",this.state=0,this.fulfillValue=void 0,this.rejectReason=void 0,this.onFulfilled=[],this.onRejected=[],this.proxy={then:this.then.bind(this)},"function"==typeof t&&t.call(this,this.fulfill.bind(this),this.reject.bind(this))};cr.prototype={fulfill:function(e){return dr(this,1,"fulfillValue",e)},reject:function(e){return dr(this,2,"rejectReason",e)},then:function(e,t){var n=new cr;return this.onFulfilled.push(fr(e,n,"fulfill")),this.onRejected.push(fr(t,n,"reject")),hr(this),n.proxy}};var dr=function(e,t,n,r){return 0===e.state&&(e.state=t,e[n]=r,hr(e)),e},hr=function(e){1===e.state?pr(e,"onFulfilled",e.fulfillValue):2===e.state&&pr(e,"onRejected",e.rejectReason)},pr=function(e,t,n){if(0!==e[t].length){var r=e[t];e[t]=[];var i=function(){for(var e=0;e0:void 0}},clearQueue:function(){return function(){var e=void 0!==this.length?this:[this];if(!(this._private.cy||this).styleEnabled())return this;for(var t=0;t-1};var ri=function(e,t){var n=this.__data__,r=Qr(n,e);return r<0?(++this.size,n.push([e,t])):n[r][1]=t,this};function ii(e){var t=-1,n=null==e?0:e.length;for(this.clear();++t-1&&e%1==0&&e0&&this.spawn(n).updateStyle().emit("class"),this},addClass:function(e){return this.toggleClass(e,!0)},hasClass:function(e){var t=this[0];return null!=t&&t._private.classes.has(e)},toggleClass:function(e,t){m(e)||(e=e.match(/\S+/g)||[]);for(var n=void 0===t,r=[],i=0,a=this.length;i0&&this.spawn(r).updateStyle().emit("class"),this},removeClass:function(e){return this.toggleClass(e,!1)},flashClass:function(e,t){var n=this;if(null==t)t=250;else if(0===t)return n;return n.addClass(e),setTimeout((function(){n.removeClass(e)}),t),n}};qi.className=qi.classNames=qi.classes;var Yi={metaChar:"[\\!\\\"\\#\\$\\%\\&\\'\\(\\)\\*\\+\\,\\.\\/\\:\\;\\<\\=\\>\\?\\@\\[\\]\\^\\`\\{\\|\\}\\~]",comparatorOp:"=|\\!=|>|>=|<|<=|\\$=|\\^=|\\*=",boolOp:"\\?|\\!|\\^",string:"\"(?:\\\\\"|[^\"])*\"|'(?:\\\\'|[^'])*'",number:I,meta:"degree|indegree|outdegree",separator:"\\s*,\\s*",descendant:"\\s+",child:"\\s+>\\s+",subject:"\\$",group:"node|edge|\\*",directedEdge:"\\s+->\\s+",undirectedEdge:"\\s+<->\\s+"};Yi.variable="(?:[\\w-.]|(?:\\\\"+Yi.metaChar+"))+",Yi.className="(?:[\\w-]|(?:\\\\"+Yi.metaChar+"))+",Yi.value=Yi.string+"|"+Yi.number,Yi.id=Yi.variable,function(){var e,t,n;for(e=Yi.comparatorOp.split("|"),n=0;n=0||"="!==t&&(Yi.comparatorOp+="|\\!"+t)}();var Xi=0,Wi=1,Hi=2,Ki=3,Gi=4,Ui=5,Zi=6,$i=7,Qi=8,Ji=9,ea=10,ta=11,na=12,ra=13,ia=14,aa=15,oa=16,sa=17,la=18,ua=19,ca=20,da=[{selector:":selected",matches:function(e){return e.selected()}},{selector:":unselected",matches:function(e){return!e.selected()}},{selector:":selectable",matches:function(e){return e.selectable()}},{selector:":unselectable",matches:function(e){return!e.selectable()}},{selector:":locked",matches:function(e){return e.locked()}},{selector:":unlocked",matches:function(e){return!e.locked()}},{selector:":visible",matches:function(e){return e.visible()}},{selector:":hidden",matches:function(e){return!e.visible()}},{selector:":transparent",matches:function(e){return e.transparent()}},{selector:":grabbed",matches:function(e){return e.grabbed()}},{selector:":free",matches:function(e){return!e.grabbed()}},{selector:":removed",matches:function(e){return e.removed()}},{selector:":inside",matches:function(e){return!e.removed()}},{selector:":grabbable",matches:function(e){return e.grabbable()}},{selector:":ungrabbable",matches:function(e){return!e.grabbable()}},{selector:":animated",matches:function(e){return e.animated()}},{selector:":unanimated",matches:function(e){return!e.animated()}},{selector:":parent",matches:function(e){return e.isParent()}},{selector:":childless",matches:function(e){return e.isChildless()}},{selector:":child",matches:function(e){return e.isChild()}},{selector:":orphan",matches:function(e){return e.isOrphan()}},{selector:":nonorphan",matches:function(e){return e.isChild()}},{selector:":compound",matches:function(e){return e.isNode()?e.isParent():e.source().isParent()||e.target().isParent()}},{selector:":loop",matches:function(e){return e.isLoop()}},{selector:":simple",matches:function(e){return e.isSimple()}},{selector:":active",matches:function(e){return e.active()}},{selector:":inactive",matches:function(e){return!e.active()}},{selector:":backgrounding",matches:function(e){return e.backgrounding()}},{selector:":nonbackgrounding",matches:function(e){return!e.backgrounding()}}].sort((function(e,t){return function(e,t){return-1*A(e,t)}(e.selector,t.selector)})),ha=function(){for(var e,t={},n=0;n0&&l.edgeCount>0)return je("The selector `"+e+"` is invalid because it uses both a compound selector and an edge selector"),!1;if(l.edgeCount>1)return je("The selector `"+e+"` is invalid because it uses multiple edge selectors"),!1;1===l.edgeCount&&je("The selector `"+e+"` is deprecated. Edge selectors do not take effect on changes to source and target nodes after an edge is added, for performance reasons. Use a class or data selector on edges instead, updating the class or data of an edge when your app detects a change in source or target nodes.")}return!0},toString:function(){if(null!=this.toStringCache)return this.toStringCache;for(var e=function(e){return null==e?"":e},t=function(t){return v(t)?'"'+t+'"':e(t)},n=function(e){return" "+e+" "},r=function(r,a){var o=r.type,s=r.value;switch(o){case Xi:var l=e(s);return l.substring(0,l.length-1);case Ki:var u=r.field,c=r.operator;return"["+u+n(e(c))+t(s)+"]";case Ui:var d=r.operator,h=r.field;return"["+e(d)+h+"]";case Gi:return"["+r.field+"]";case Zi:var p=r.operator;return"[["+r.field+n(e(p))+t(s)+"]]";case $i:return s;case Qi:return"#"+s;case Ji:return"."+s;case sa:case aa:return i(r.parent,a)+n(">")+i(r.child,a);case la:case oa:return i(r.ancestor,a)+" "+i(r.descendant,a);case ua:var f=i(r.left,a),g=i(r.subject,a),v=i(r.right,a);return f+(f.length>0?" ":"")+g+v;case ca:return""}},i=function(e,t){return e.checks.reduce((function(n,i,a){return n+(t===e&&0===a?"$":"")+r(i,t)}),"")},a="",o=0;o1&&o=0&&(t=t.replace("!",""),c=!0),t.indexOf("@")>=0&&(t=t.replace("@",""),u=!0),(o||l||u)&&(i=o||s?""+e:"",a=""+n),u&&(e=i=i.toLowerCase(),n=a=a.toLowerCase()),t){case"*=":r=i.indexOf(a)>=0;break;case"$=":r=i.indexOf(a,i.length-a.length)>=0;break;case"^=":r=0===i.indexOf(a);break;case"=":r=e===n;break;case">":d=!0,r=e>n;break;case">=":d=!0,r=e>=n;break;case"<":d=!0,r=e0;){var u=i.shift();t(u),a.add(u.id()),o&&r(i,a,u)}return e}function Ba(e,t,n){if(n.isParent())for(var r=n._private.children,i=0;i1&&void 0!==arguments[1])||arguments[1];return Ma(this,e,t,Ba)},_a.forEachUp=function(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];return Ma(this,e,t,Na)},_a.forEachUpAndDown=function(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];return Ma(this,e,t,za)},_a.ancestors=_a.parents,(Pa=Da={data:Fi.data({field:"data",bindingEvent:"data",allowBinding:!0,allowSetting:!0,settingEvent:"data",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,immutableKeys:{id:!0,source:!0,target:!0,parent:!0},updateStyle:!0}),removeData:Fi.removeData({field:"data",event:"data",triggerFnName:"trigger",triggerEvent:!0,immutableKeys:{id:!0,source:!0,target:!0,parent:!0},updateStyle:!0}),scratch:Fi.data({field:"scratch",bindingEvent:"scratch",allowBinding:!0,allowSetting:!0,settingEvent:"scratch",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeScratch:Fi.removeData({field:"scratch",event:"scratch",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0}),rscratch:Fi.data({field:"rscratch",allowBinding:!1,allowSetting:!0,settingTriggersEvent:!1,allowGetting:!0}),removeRscratch:Fi.removeData({field:"rscratch",triggerEvent:!1}),id:function(){var e=this[0];if(e)return e._private.data.id}}).attr=Pa.data,Pa.removeAttr=Pa.removeData;var Ia,Aa,La=Da,Oa={};function Ra(e){return function(t){if(void 0===t&&(t=!0),0!==this.length&&this.isNode()&&!this.removed()){for(var n=0,r=this[0],i=r._private.edges,a=0;at})),minIndegree:Va("indegree",(function(e,t){return et})),minOutdegree:Va("outdegree",(function(e,t){return et}))}),L(Oa,{totalDegree:function(e){for(var t=0,n=this.nodes(),r=0;r0,c=u;u&&(l=l[0]);var d=c?l.position():{x:0,y:0};return i={x:s.x-d.x,y:s.y-d.y},void 0===e?i:i[e]}for(var h=0;h0,y=g;g&&(f=f[0]);var m=y?f.position():{x:0,y:0};void 0!==t?p.position(e,t+m[e]):void 0!==i&&p.position({x:i.x+m.x,y:i.y+m.y})}}else if(!a)return;return this}}).modelPosition=Ia.point=Ia.position,Ia.modelPositions=Ia.points=Ia.positions,Ia.renderedPoint=Ia.renderedPosition,Ia.relativePoint=Ia.relativePosition;var qa,Ya,Xa=Aa;qa=Ya={},Ya.renderedBoundingBox=function(e){var t=this.boundingBox(e),n=this.cy(),r=n.zoom(),i=n.pan(),a=t.x1*r+i.x,o=t.x2*r+i.x,s=t.y1*r+i.y,l=t.y2*r+i.y;return{x1:a,x2:o,y1:s,y2:l,w:o-a,h:l-s}},Ya.dirtyCompoundBoundsCache=function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0],t=this.cy();return t.styleEnabled()&&t.hasCompoundNodes()?(this.forEachUp((function(t){if(t.isParent()){var n=t._private;n.compoundBoundsClean=!1,n.bbCache=null,e||t.emitAndNotify("bounds")}})),this):this},Ya.updateCompoundBounds=function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0],t=this.cy();if(!t.styleEnabled()||!t.hasCompoundNodes())return this;if(!e&&t.batching())return this;function n(e){if(e.isParent()){var t=e._private,n=e.children(),r="include"===e.pstyle("compound-sizing-wrt-labels").value,i={width:{val:e.pstyle("min-width").pfValue,left:e.pstyle("min-width-bias-left"),right:e.pstyle("min-width-bias-right")},height:{val:e.pstyle("min-height").pfValue,top:e.pstyle("min-height-bias-top"),bottom:e.pstyle("min-height-bias-bottom")}},a=n.boundingBox({includeLabels:r,includeOverlays:!1,useCache:!1}),o=t.position;0!==a.w&&0!==a.h||((a={w:e.pstyle("width").pfValue,h:e.pstyle("height").pfValue}).x1=o.x-a.w/2,a.x2=o.x+a.w/2,a.y1=o.y-a.h/2,a.y2=o.y+a.h/2);var s=i.width.left.value;"px"===i.width.left.units&&i.width.val>0&&(s=100*s/i.width.val);var l=i.width.right.value;"px"===i.width.right.units&&i.width.val>0&&(l=100*l/i.width.val);var u=i.height.top.value;"px"===i.height.top.units&&i.height.val>0&&(u=100*u/i.height.val);var c=i.height.bottom.value;"px"===i.height.bottom.units&&i.height.val>0&&(c=100*c/i.height.val);var d=y(i.width.val-a.w,s,l),h=d.biasDiff,p=d.biasComplementDiff,f=y(i.height.val-a.h,u,c),g=f.biasDiff,v=f.biasComplementDiff;t.autoPadding=function(e,t,n,r){if("%"!==n.units)return"px"===n.units?n.pfValue:0;switch(r){case"width":return e>0?n.pfValue*e:0;case"height":return t>0?n.pfValue*t:0;case"average":return e>0&&t>0?n.pfValue*(e+t)/2:0;case"min":return e>0&&t>0?e>t?n.pfValue*t:n.pfValue*e:0;case"max":return e>0&&t>0?e>t?n.pfValue*e:n.pfValue*t:0;default:return 0}}(a.w,a.h,e.pstyle("padding"),e.pstyle("padding-relative-to").value),t.autoWidth=Math.max(a.w,i.width.val),o.x=(-h+a.x1+a.x2+p)/2,t.autoHeight=Math.max(a.h,i.height.val),o.y=(-g+a.y1+a.y2+v)/2}function y(e,t,n){var r=0,i=0,a=t+n;return e>0&&a>0&&(r=t/a*e,i=n/a*e),{biasDiff:r,biasComplementDiff:i}}}for(var r=0;re.x2?r:e.x2,e.y1=ne.y2?i:e.y2,e.w=e.x2-e.x1,e.h=e.y2-e.y1)},Ka=function(e,t){return null==t?e:Ha(e,t.x1,t.y1,t.x2,t.y2)},Ga=function(e,t,n){return Ue(e,t,n)},Ua=function(e,t,n){if(!t.cy().headless()){var r,i,a=t._private,o=a.rstyle,s=o.arrowWidth/2;if("none"!==t.pstyle(n+"-arrow-shape").value){"source"===n?(r=o.srcX,i=o.srcY):"target"===n?(r=o.tgtX,i=o.tgtY):(r=o.midX,i=o.midY);var l=a.arrowBounds=a.arrowBounds||{},u=l[n]=l[n]||{};u.x1=r-s,u.y1=i-s,u.x2=r+s,u.y2=i+s,u.w=u.x2-u.x1,u.h=u.y2-u.y1,Nt(u,1),Ha(e,u.x1,u.y1,u.x2,u.y2)}}},Za=function(e,t,n){if(!t.cy().headless()){var r;r=n?n+"-":"";var i=t._private,a=i.rstyle;if(t.pstyle(r+"label").strValue){var o,s,l,u,c=t.pstyle("text-halign"),d=t.pstyle("text-valign"),h=Ga(a,"labelWidth",n),p=Ga(a,"labelHeight",n),f=Ga(a,"labelX",n),g=Ga(a,"labelY",n),v=t.pstyle(r+"text-margin-x").pfValue,y=t.pstyle(r+"text-margin-y").pfValue,m=t.isEdge(),b=t.pstyle(r+"text-rotation"),x=t.pstyle("text-outline-width").pfValue,w=t.pstyle("text-border-width").pfValue/2,E=t.pstyle("text-background-padding").pfValue,k=p,C=h,S=C/2,P=k/2;if(m)o=f-S,s=f+S,l=g-P,u=g+P;else{switch(c.value){case"left":o=f-C,s=f;break;case"center":o=f-S,s=f+S;break;case"right":o=f,s=f+C}switch(d.value){case"top":l=g-k,u=g;break;case"center":l=g-P,u=g+P;break;case"bottom":l=g,u=g+k}}var D=v-Math.max(x,w)-E-2,T=v+Math.max(x,w)+E+2,_=y-Math.max(x,w)-E-2,M=y+Math.max(x,w)+E+2;o+=D,s+=T,l+=_,u+=M;var B=n||"main",N=i.labelBounds,z=N[B]=N[B]||{};z.x1=o,z.y1=l,z.x2=s,z.y2=u,z.w=s-o,z.h=u-l,z.leftPad=D,z.rightPad=T,z.topPad=_,z.botPad=M;var I=m&&"autorotate"===b.strValue,A=null!=b.pfValue&&0!==b.pfValue;if(I||A){var L=I?Ga(i.rstyle,"labelAngle",n):b.pfValue,O=Math.cos(L),R=Math.sin(L),V=(o+s)/2,F=(l+u)/2;if(!m){switch(c.value){case"left":V=s;break;case"right":V=o}switch(d.value){case"top":F=u;break;case"bottom":F=l}}var j=function(e,t){return{x:(e-=V)*O-(t-=F)*R+V,y:e*R+t*O+F}},q=j(o,l),Y=j(o,u),X=j(s,l),W=j(s,u);o=Math.min(q.x,Y.x,X.x,W.x),s=Math.max(q.x,Y.x,X.x,W.x),l=Math.min(q.y,Y.y,X.y,W.y),u=Math.max(q.y,Y.y,X.y,W.y)}var H=B+"Rot",K=N[H]=N[H]||{};K.x1=o,K.y1=l,K.x2=s,K.y2=u,K.w=s-o,K.h=u-l,Ha(e,o,l,s,u),Ha(i.labelBounds.all,o,l,s,u)}return e}},$a=function(e,t){var n,r,i,a,o,s,l,u=e._private.cy,c=u.styleEnabled(),d=u.headless(),h=_t(),p=e._private,f=e.isNode(),g=e.isEdge(),v=p.rstyle,y=f&&c?e.pstyle("bounds-expansion").pfValue:[0],m=function(e){return"none"!==e.pstyle("display").value},b=!c||m(e)&&(!g||m(e.source())&&m(e.target()));if(b){var x=0;c&&t.includeOverlays&&0!==e.pstyle("overlay-opacity").value&&(x=e.pstyle("overlay-padding").value);var w=0;c&&t.includeUnderlays&&0!==e.pstyle("underlay-opacity").value&&(w=e.pstyle("underlay-padding").value);var E=Math.max(x,w),k=0;if(c&&(k=e.pstyle("width").pfValue/2),f&&t.includeNodes){var C=e.position();o=C.x,s=C.y;var S=e.outerWidth()/2,P=e.outerHeight()/2;Ha(h,n=o-S,i=s-P,r=o+S,a=s+P),c&&t.includeOutlines&&function(e,t){if(!t.cy().headless()){var n,r,i,a=t.pstyle("outline-opacity").value,o=t.pstyle("outline-width").value;if(a>0&&o>0){var s=t.pstyle("outline-offset").value,l=t.pstyle("shape").value,u=o+s,c=(e.w+2*u)/e.w,d=(e.h+2*u)/e.h,h=0;["diamond","pentagon","round-triangle"].includes(l)?(c=(e.w+2.4*u)/e.w,h=-u/3.6):["concave-hexagon","rhomboid","right-rhomboid"].includes(l)?c=(e.w+2.4*u)/e.w:"star"===l?(c=(e.w+2.8*u)/e.w,d=(e.h+2.6*u)/e.h,h=-u/3.8):"triangle"===l?(c=(e.w+2.8*u)/e.w,d=(e.h+2.4*u)/e.h,h=-u/1.4):"vee"===l&&(c=(e.w+4.4*u)/e.w,d=(e.h+3.8*u)/e.h,h=.5*-u);var p=e.h*d-e.h,f=e.w*c-e.w;if(zt(e,[Math.ceil(p/2),Math.ceil(f/2)]),0!==h){var g=(r=0,i=h,{x1:(n=e).x1+r,x2:n.x2+r,y1:n.y1+i,y2:n.y2+i,w:n.w,h:n.h});Mt(e,g)}}}}(h,e)}else if(g&&t.includeEdges)if(c&&!d){var D=e.pstyle("curve-style").strValue;if(n=Math.min(v.srcX,v.midX,v.tgtX),r=Math.max(v.srcX,v.midX,v.tgtX),i=Math.min(v.srcY,v.midY,v.tgtY),a=Math.max(v.srcY,v.midY,v.tgtY),Ha(h,n-=k,i-=k,r+=k,a+=k),"haystack"===D){var T=v.haystackPts;if(T&&2===T.length){if(n=T[0].x,i=T[0].y,n>(r=T[1].x)){var _=n;n=r,r=_}if(i>(a=T[1].y)){var M=i;i=a,a=M}Ha(h,n-k,i-k,r+k,a+k)}}else if("bezier"===D||"unbundled-bezier"===D||D.endsWith("segments")||D.endsWith("taxi")){var B;switch(D){case"bezier":case"unbundled-bezier":B=v.bezierPts;break;case"segments":case"taxi":case"round-segments":case"round-taxi":B=v.linePts}if(null!=B)for(var N=0;N(r=A.x)){var L=n;n=r,r=L}if((i=I.y)>(a=A.y)){var O=i;i=a,a=O}Ha(h,n-=k,i-=k,r+=k,a+=k)}if(c&&t.includeEdges&&g&&(Ua(h,e,"mid-source"),Ua(h,e,"mid-target"),Ua(h,e,"source"),Ua(h,e,"target")),c)if("yes"===e.pstyle("ghost").value){var R=e.pstyle("ghost-offset-x").pfValue,V=e.pstyle("ghost-offset-y").pfValue;Ha(h,h.x1+R,h.y1+V,h.x2+R,h.y2+V)}var F=p.bodyBounds=p.bodyBounds||{};It(F,h),zt(F,y),Nt(F,1),c&&(n=h.x1,r=h.x2,i=h.y1,a=h.y2,Ha(h,n-E,i-E,r+E,a+E));var j=p.overlayBounds=p.overlayBounds||{};It(j,h),zt(j,y),Nt(j,1);var q=p.labelBounds=p.labelBounds||{};null!=q.all?((l=q.all).x1=1/0,l.y1=1/0,l.x2=-1/0,l.y2=-1/0,l.w=0,l.h=0):q.all=_t(),c&&t.includeLabels&&(t.includeMainLabels&&Za(h,e,null),g&&(t.includeSourceLabels&&Za(h,e,"source"),t.includeTargetLabels&&Za(h,e,"target")))}return h.x1=Wa(h.x1),h.y1=Wa(h.y1),h.x2=Wa(h.x2),h.y2=Wa(h.y2),h.w=Wa(h.x2-h.x1),h.h=Wa(h.y2-h.y1),h.w>0&&h.h>0&&b&&(zt(h,y),Nt(h,1)),h},Qa=function(e){var t=0,n=function(e){return(e?1:0)<0&&void 0!==arguments[0]?arguments[0]:bo,t=arguments.length>1?arguments[1]:void 0,n=0;n=0;s--)o(s);return this},wo.removeAllListeners=function(){return this.removeListener("*")},wo.emit=wo.trigger=function(e,t,n){var r=this.listeners,i=r.length;return this.emitting++,m(t)||(t=[t]),Co(this,(function(e,a){null!=n&&(r=[{event:a.event,type:a.type,namespace:a.namespace,callback:n}],i=r.length);for(var o=function(n){var i=r[n];if(i.type===a.type&&(!i.namespace||i.namespace===a.namespace||".*"===i.namespace)&&e.eventMatches(e.context,i,a)){var o=[a];null!=t&&function(e,t){for(var n=0;n1&&!r){var i=this.length-1,a=this[i],o=a._private.data.id;this[i]=void 0,this[e]=a,n.set(o,{ele:a,index:e})}return this.length--,this},unmergeOne:function(e){e=e[0];var t=this._private,n=e._private.data.id,r=t.map.get(n);if(!r)return this;var i=r.index;return this.unmergeAt(i),this},unmerge:function(e){var t=this._private.cy;if(!e)return this;if(e&&v(e)){var n=e;e=t.mutableElements().filter(n)}for(var r=0;r=0;t--){e(this[t])&&this.unmergeAt(t)}return this},map:function(e,t){for(var n=[],r=0;rr&&(r=o,n=a)}return{value:r,ele:n}},min:function(e,t){for(var n,r=1/0,i=0;i=0&&i1&&void 0!==arguments[1])||arguments[1],n=this[0],r=n.cy();if(r.styleEnabled()&&n){this.cleanStyle();var i=n._private.style[e];return null!=i?i:t?r.style().getDefaultProperty(e):null}},numericStyle:function(e){var t=this[0];if(t.cy().styleEnabled()&&t){var n=t.pstyle(e);return void 0!==n.pfValue?n.pfValue:n.value}},numericStyleUnits:function(e){var t=this[0];if(t.cy().styleEnabled())return t?t.pstyle(e).units:void 0},renderedStyle:function(e){var t=this.cy();if(!t.styleEnabled())return this;var n=this[0];return n?t.style().getRenderedStyle(n,e):void 0},style:function(e,t){var n=this.cy();if(!n.styleEnabled())return this;var r=n.style();if(b(e)){var i=e;r.applyBypass(this,i,!1),this.emitAndNotify("style")}else if(v(e)){if(void 0===t){var a=this[0];return a?r.getStylePropertyValue(a,e):void 0}r.applyBypass(this,e,t,!1),this.emitAndNotify("style")}else if(void 0===e){var o=this[0];return o?r.getRawStyle(o):void 0}return this},removeStyle:function(e){var t=this.cy();if(!t.styleEnabled())return this;var n=t.style();if(void 0===e)for(var r=0;r0&&t.push(c[0]),t.push(s[0])}return this.spawn(t,!0).filter(e)}),"neighborhood"),closedNeighborhood:function(e){return this.neighborhood().add(this).filter(e)},openNeighborhood:function(e){return this.neighborhood(e)}}),Go.neighbourhood=Go.neighborhood,Go.closedNeighbourhood=Go.closedNeighborhood,Go.openNeighbourhood=Go.openNeighborhood,L(Go,{source:Ta((function(e){var t,n=this[0];return n&&(t=n._private.source||n.cy().collection()),t&&e?t.filter(e):t}),"source"),target:Ta((function(e){var t,n=this[0];return n&&(t=n._private.target||n.cy().collection()),t&&e?t.filter(e):t}),"target"),sources:Qo({attr:"source"}),targets:Qo({attr:"target"})}),L(Go,{edgesWith:Ta(Jo(),"edgesWith"),edgesTo:Ta(Jo({thisIsSrc:!0}),"edgesTo")}),L(Go,{connectedEdges:Ta((function(e){for(var t=[],n=0;n0);return a},component:function(){var e=this[0];return e.cy().mutableElements().components(e)[0]}}),Go.componentsOf=Go.components;var ts=function(e,t){var n=arguments.length>2&&void 0!==arguments[2]&&arguments[2],r=arguments.length>3&&void 0!==arguments[3]&&arguments[3];if(void 0!==e){var i=new $e,a=!1;if(t){if(t.length>0&&b(t[0])&&!k(t[0])){a=!0;for(var o=[],s=new Je,l=0,u=t.length;l0&&void 0!==arguments[0])||arguments[0],r=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],i=this,a=i.cy(),o=a._private,s=[],l=[],u=0,c=i.length;u0){for(var R=e.length===i.length?i:new ts(a,e),V=0;V0&&void 0!==arguments[0])||arguments[0],t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],n=this,r=[],i={},a=n._private.cy;function o(e){for(var t=e._private.edges,n=0;n0&&(e?D.emitAndNotify("remove"):t&&D.emit("remove"));for(var T=0;T1e-4&&Math.abs(s.v)>1e-4;);return a?function(e){return u[e*(u.length-1)|0]}:c}}(),as=function(e,t,n,r){var i=function(e,t,n,r){var i=4,a=.001,o=1e-7,s=10,l=11,u=1/(l-1),c="undefined"!=typeof Float32Array;if(4!==arguments.length)return!1;for(var d=0;d<4;++d)if("number"!=typeof arguments[d]||isNaN(arguments[d])||!isFinite(arguments[d]))return!1;e=Math.min(e,1),n=Math.min(n,1),e=Math.max(e,0),n=Math.max(n,0);var h=c?new Float32Array(l):new Array(l);function p(e,t){return 1-3*t+3*e}function f(e,t){return 3*t-6*e}function g(e){return 3*e}function v(e,t,n){return((p(t,n)*e+f(t,n))*e+g(t))*e}function y(e,t,n){return 3*p(t,n)*e*e+2*f(t,n)*e+g(t)}function m(t,r){for(var a=0;a0?i=l:r=l}while(Math.abs(a)>o&&++u=a?m(t,s):0===c?s:x(t,r,r+u)}var E=!1;function k(){E=!0,e===t&&n===r||b()}var C=function(i){return E||k(),e===t&&n===r?i:0===i?0:1===i?1:v(w(i),t,r)};C.getControlPoints=function(){return[{x:e,y:t},{x:n,y:r}]};var S="generateBezier("+[e,t,n,r]+")";return C.toString=function(){return S},C}(e,t,n,r);return function(e,t,n){return e+(t-e)*i(n)}},os={linear:function(e,t,n){return e+(t-e)*n},ease:as(.25,.1,.25,1),"ease-in":as(.42,0,1,1),"ease-out":as(0,0,.58,1),"ease-in-out":as(.42,0,.58,1),"ease-in-sine":as(.47,0,.745,.715),"ease-out-sine":as(.39,.575,.565,1),"ease-in-out-sine":as(.445,.05,.55,.95),"ease-in-quad":as(.55,.085,.68,.53),"ease-out-quad":as(.25,.46,.45,.94),"ease-in-out-quad":as(.455,.03,.515,.955),"ease-in-cubic":as(.55,.055,.675,.19),"ease-out-cubic":as(.215,.61,.355,1),"ease-in-out-cubic":as(.645,.045,.355,1),"ease-in-quart":as(.895,.03,.685,.22),"ease-out-quart":as(.165,.84,.44,1),"ease-in-out-quart":as(.77,0,.175,1),"ease-in-quint":as(.755,.05,.855,.06),"ease-out-quint":as(.23,1,.32,1),"ease-in-out-quint":as(.86,0,.07,1),"ease-in-expo":as(.95,.05,.795,.035),"ease-out-expo":as(.19,1,.22,1),"ease-in-out-expo":as(1,0,0,1),"ease-in-circ":as(.6,.04,.98,.335),"ease-out-circ":as(.075,.82,.165,1),"ease-in-out-circ":as(.785,.135,.15,.86),spring:function(e,t,n){if(0===n)return os.linear;var r=is(e,t,n);return function(e,t,n){return e+(t-e)*r(n)}},"cubic-bezier":as};function ss(e,t,n,r,i){if(1===r)return n;if(t===n)return n;var a=i(t,n,r);return null==e||((e.roundValue||e.color)&&(a=Math.round(a)),void 0!==e.min&&(a=Math.max(a,e.min)),void 0!==e.max&&(a=Math.min(a,e.max))),a}function ls(e,t){return null!=e.pfValue||null!=e.value?null==e.pfValue||null!=t&&"%"===t.type.units?e.value:e.pfValue:e}function us(e,t,n,r,i){var a=null!=i?i.type:null;n<0?n=0:n>1&&(n=1);var o=ls(e,i),s=ls(t,i);if(x(o)&&x(s))return ss(a,o,s,n,r);if(m(o)&&m(s)){for(var l=[],u=0;u0?("spring"===d&&h.push(o.duration),o.easingImpl=os[d].apply(null,h)):o.easingImpl=os[d]}var p,f=o.easingImpl;if(p=0===o.duration?1:(n-l)/o.duration,o.applying&&(p=o.progress),p<0?p=0:p>1&&(p=1),null==o.delay){var g=o.startPosition,y=o.position;if(y&&i&&!e.locked()){var m={};ds(g.x,y.x)&&(m.x=us(g.x,y.x,p,f)),ds(g.y,y.y)&&(m.y=us(g.y,y.y,p,f)),e.position(m)}var b=o.startPan,x=o.pan,w=a.pan,E=null!=x&&r;E&&(ds(b.x,x.x)&&(w.x=us(b.x,x.x,p,f)),ds(b.y,x.y)&&(w.y=us(b.y,x.y,p,f)),e.emit("pan"));var k=o.startZoom,C=o.zoom,S=null!=C&&r;S&&(ds(k,C)&&(a.zoom=Tt(a.minZoom,us(k,C,p,f),a.maxZoom)),e.emit("zoom")),(E||S)&&e.emit("viewport");var P=o.style;if(P&&P.length>0&&i){for(var D=0;D=0;t--){(0,e[t])()}e.splice(0,e.length)},c=a.length-1;c>=0;c--){var d=a[c],h=d._private;h.stopped?(a.splice(c,1),h.hooked=!1,h.playing=!1,h.started=!1,u(h.frames)):(h.playing||h.applying)&&(h.playing&&h.applying&&(h.applying=!1),h.started||hs(0,d,e),cs(t,d,e,n),h.applying&&(h.applying=!1),u(h.frames),null!=h.step&&h.step(e),d.completed()&&(a.splice(c,1),h.hooked=!1,h.playing=!1,h.started=!1,u(h.completes)),s=!0)}return n||0!==a.length||0!==o.length||r.push(t),s}for(var a=!1,o=0;o0?t.notify("draw",n):t.notify("draw")),n.unmerge(r),t.emit("step")}var fs={animate:Fi.animate(),animation:Fi.animation(),animated:Fi.animated(),clearQueue:Fi.clearQueue(),delay:Fi.delay(),delayAnimation:Fi.delayAnimation(),stop:Fi.stop(),addToAnimationPool:function(e){this.styleEnabled()&&this._private.aniEles.merge(e)},stopAnimationLoop:function(){this._private.animationsRunning=!1},startAnimationLoop:function(){var e=this;if(e._private.animationsRunning=!0,e.styleEnabled()){var t=e.renderer();t&&t.beforeRender?t.beforeRender((function(t,n){ps(n,e)}),t.beforeRenderPriorities.animations):function t(){e._private.animationsRunning&&xe((function(n){ps(n,e),t()}))}()}}},gs={qualifierCompare:function(e,t){return null==e||null==t?null==e&&null==t:e.sameText(t)},eventMatches:function(e,t,n){var r=t.qualifier;return null==r||e!==n.target&&k(n.target)&&r.matches(n.target)},addEventFields:function(e,t){t.cy=e,t.target=e},callbackContext:function(e,t,n){return null!=t.qualifier?n.target:e}},vs=function(e){return v(e)?new ka(e):e},ys={createEmitter:function(){var e=this._private;return e.emitter||(e.emitter=new xo(gs,this)),this},emitter:function(){return this._private.emitter},on:function(e,t,n){return this.emitter().on(e,vs(t),n),this},removeListener:function(e,t,n){return this.emitter().removeListener(e,vs(t),n),this},removeAllListeners:function(){return this.emitter().removeAllListeners(),this},one:function(e,t,n){return this.emitter().one(e,vs(t),n),this},once:function(e,t,n){return this.emitter().one(e,vs(t),n),this},emit:function(e,t){return this.emitter().emit(e,t),this},emitAndNotify:function(e,t){return this.emit(e),this.notify(e,t),this}};Fi.eventAliasesOn(ys);var ms={png:function(e){return e=e||{},this._private.renderer.png(e)},jpg:function(e){var t=this._private.renderer;return(e=e||{}).bg=e.bg||"#fff",t.jpg(e)}};ms.jpeg=ms.jpg;var bs={layout:function(e){if(null!=e)if(null!=e.name){var t=e.name,n=this.extension("layout",t);if(null!=n){var r;r=v(e.eles)?this.$(e.eles):null!=e.eles?e.eles:this.$();var i=new n(L({},e,{cy:this,eles:r}));return i}Ve("No such layout `"+t+"` found. Did you forget to import it and `cytoscape.use()` it?")}else Ve("A `name` must be specified to make a layout");else Ve("Layout options must be specified to make a layout")}};bs.createLayout=bs.makeLayout=bs.layout;var xs={notify:function(e,t){var n=this._private;if(this.batching()){n.batchNotifications=n.batchNotifications||{};var r=n.batchNotifications[e]=n.batchNotifications[e]||this.collection();null!=t&&r.merge(t)}else if(n.notificationsEnabled){var i=this.renderer();!this.destroyed()&&i&&i.notify(e,t)}},notifications:function(e){var t=this._private;return void 0===e?t.notificationsEnabled:(t.notificationsEnabled=!!e,this)},noNotifications:function(e){this.notifications(!1),e(),this.notifications(!0)},batching:function(){return this._private.batchCount>0},startBatch:function(){var e=this._private;return null==e.batchCount&&(e.batchCount=0),0===e.batchCount&&(e.batchStyleEles=this.collection(),e.batchNotifications={}),e.batchCount++,this},endBatch:function(){var e=this._private;if(0===e.batchCount)return this;if(e.batchCount--,0===e.batchCount){e.batchStyleEles.updateStyle();var t=this.renderer();Object.keys(e.batchNotifications).forEach((function(n){var r=e.batchNotifications[n];r.empty()?t.notify(n):t.notify(n,r)}))}return this},batch:function(e){return this.startBatch(),e(),this.endBatch(),this},batchData:function(e){var t=this;return this.batch((function(){for(var n=Object.keys(e),r=0;r0;)e.removeChild(e.childNodes[0]);this._private.renderer=null,this.mutableElements().forEach((function(e){var t=e._private;t.rscratch={},t.rstyle={},t.animation.current=[],t.animation.queue=[]}))},onRender:function(e){return this.on("render",e)},offRender:function(e){return this.off("render",e)}};Es.invalidateDimensions=Es.resize;var ks={collection:function(e,t){return v(e)?this.$(e):E(e)?e.collection():m(e)?(t||(t={}),new ts(this,e,t.unique,t.removed)):new ts(this)},nodes:function(e){var t=this.$((function(e){return e.isNode()}));return e?t.filter(e):t},edges:function(e){var t=this.$((function(e){return e.isEdge()}));return e?t.filter(e):t},$:function(e){var t=this._private.elements;return e?t.filter(e):t.spawnSelf()},mutableElements:function(){return this._private.elements}};ks.elements=ks.filter=ks.$;var Cs={};Cs.apply=function(e){for(var t=this._private.cy.collection(),n=0;n0;if(d||c&&h){var p=void 0;d&&h||d?p=l.properties:h&&(p=l.mappedProperties);for(var f=0;f1&&(g=1),s.color){var w=i.valueMin[0],E=i.valueMax[0],k=i.valueMin[1],C=i.valueMax[1],S=i.valueMin[2],P=i.valueMax[2],D=null==i.valueMin[3]?1:i.valueMin[3],T=null==i.valueMax[3]?1:i.valueMax[3],_=[Math.round(w+(E-w)*g),Math.round(k+(C-k)*g),Math.round(S+(P-S)*g),Math.round(D+(T-D)*g)];n={bypass:i.bypass,name:i.name,value:_,strValue:"rgb("+_[0]+", "+_[1]+", "+_[2]+")"}}else{if(!s.number)return!1;var M=i.valueMin+(i.valueMax-i.valueMin)*g;n=this.parse(i.name,M,i.bypass,"mapping")}if(!n)return f(),!1;n.mapping=i,i=n;break;case o.data:for(var B=i.field.split("."),N=d.data,z=0;z0&&a>0){for(var s={},l=!1,u=0;u0?e.delayAnimation(o).play().promise().then(t):t()})).then((function(){return e.animation({style:s,duration:a,easing:e.pstyle("transition-timing-function").value,queue:!1}).play().promise()})).then((function(){n.removeBypasses(e,i),e.emitAndNotify("style"),r.transitioning=!1}))}else r.transitioning&&(this.removeBypasses(e,i),e.emitAndNotify("style"),r.transitioning=!1)},Cs.checkTrigger=function(e,t,n,r,i,a){var o=this.properties[t],s=i(o);null!=s&&s(n,r)&&a(o)},Cs.checkZOrderTrigger=function(e,t,n,r){var i=this;this.checkTrigger(e,t,n,r,(function(e){return e.triggersZOrder}),(function(){i._private.cy.notify("zorder",e)}))},Cs.checkBoundsTrigger=function(e,t,n,r){this.checkTrigger(e,t,n,r,(function(e){return e.triggersBounds}),(function(i){e.dirtyCompoundBoundsCache(),e.dirtyBoundingBoxCache(),!i.triggersBoundsOfParallelBeziers||"curve-style"!==t||"bezier"!==n&&"bezier"!==r||e.parallelEdges().forEach((function(e){e.dirtyBoundingBoxCache()})),!i.triggersBoundsOfConnectedEdges||"display"!==t||"none"!==n&&"none"!==r||e.connectedEdges().forEach((function(e){e.dirtyBoundingBoxCache()}))}))},Cs.checkTriggers=function(e,t,n,r){e.dirtyStyleCache(),this.checkZOrderTrigger(e,t,n,r),this.checkBoundsTrigger(e,t,n,r)};var Ss={applyBypass:function(e,t,n,r){var i=[];if("*"===t||"**"===t){if(void 0!==n)for(var a=0;at.length?i.substr(t.length):""}function o(){n=n.length>r.length?n.substr(r.length):""}for(i=i.replace(/[/][*](\s|.)+?[*][/]/g,"");;){if(i.match(/^\s*$/))break;var s=i.match(/^\s*((?:.|\s)+?)\s*\{((?:.|\s)+?)\}/);if(!s){je("Halting stylesheet parsing: String stylesheet contains more to parse but no selector and block found in: "+i);break}t=s[0];var l=s[1];if("core"!==l)if(new ka(l).invalid){je("Skipping parsing of block: Invalid selector found in string stylesheet: "+l),a();continue}var u=s[2],c=!1;n=u;for(var d=[];;){if(n.match(/^\s*$/))break;var h=n.match(/^\s*(.+?)\s*:\s*(.+?)(?:\s*;|\s*$)/);if(!h){je("Skipping parsing of block: Invalid formatting of style property and value definitions found in:"+u),c=!0;break}r=h[0];var p=h[1],f=h[2];if(this.properties[p])this.parse(p,f)?(d.push({name:p,val:f}),o()):(je("Skipping property: Invalid property definition in: "+r),o());else je("Skipping property: Invalid property name in: "+r),o()}if(c){a();break}this.selector(l);for(var g=0;g=7&&"d"===t[0]&&(l=new RegExp(o.data.regex).exec(t))){if(n)return!1;var d=o.data;return{name:e,value:l,strValue:""+t,mapped:d,field:l[1],bypass:n}}if(t.length>=10&&"m"===t[0]&&(u=new RegExp(o.mapData.regex).exec(t))){if(n)return!1;if(c.multiple)return!1;var h=o.mapData;if(!c.color&&!c.number)return!1;var p=this.parse(e,u[4]);if(!p||p.mapped)return!1;var f=this.parse(e,u[5]);if(!f||f.mapped)return!1;if(p.pfValue===f.pfValue||p.strValue===f.strValue)return je("`"+e+": "+t+"` is not a valid mapper because the output range is zero; converting to `"+e+": "+p.strValue+"`"),this.parse(e,p.strValue);if(c.color){var g=p.value,b=f.value;if(!(g[0]!==b[0]||g[1]!==b[1]||g[2]!==b[2]||g[3]!==b[3]&&(null!=g[3]&&1!==g[3]||null!=b[3]&&1!==b[3])))return!1}return{name:e,value:u,strValue:""+t,mapped:h,field:u[1],fieldMin:parseFloat(u[2]),fieldMax:parseFloat(u[3]),valueMin:p.value,valueMax:f.value,bypass:n}}}if(c.multiple&&"multiple"!==r){var w;if(w=s?t.split(/\s+/):m(t)?t:[t],c.evenMultiple&&w.length%2!=0)return null;for(var E=[],k=[],C=[],S="",P=!1,D=0;D0?" ":"")+T.strValue}return c.validate&&!c.validate(E,k)?null:c.singleEnum&&P?1===E.length&&v(E[0])?{name:e,value:E[0],strValue:E[0],bypass:n}:null:{name:e,value:E,pfValue:C,strValue:S,bypass:n,units:k}}var _,B,N=function(){for(var r=0;rc.max||c.strictMax&&t===c.max))return null;var V={name:e,value:t,strValue:""+t+(z||""),units:z,bypass:n};return c.unitless||"px"!==z&&"em"!==z?V.pfValue=t:V.pfValue="px"!==z&&z?this.getEmSizeInPixels()*t:t,"ms"!==z&&"s"!==z||(V.pfValue="ms"===z?t:1e3*t),"deg"!==z&&"rad"!==z||(V.pfValue="rad"===z?t:(_=t,Math.PI*_/180)),"%"===z&&(V.pfValue=t/100),V}if(c.propList){var F=[],j=""+t;if("none"===j);else{for(var q=j.split(/\s*,\s*|\s+/),Y=0;Y0&&l>0&&!isNaN(n.w)&&!isNaN(n.h)&&n.w>0&&n.h>0)return{zoom:o=(o=(o=Math.min((s-2*t)/n.w,(l-2*t)/n.h))>this._private.maxZoom?this._private.maxZoom:o)=n.minZoom&&(n.maxZoom=t),this},minZoom:function(e){return void 0===e?this._private.minZoom:this.zoomRange({min:e})},maxZoom:function(e){return void 0===e?this._private.maxZoom:this.zoomRange({max:e})},getZoomedViewport:function(e){var t,n,r=this._private,i=r.pan,a=r.zoom,o=!1;if(r.zoomingEnabled||(o=!0),x(e)?n=e:b(e)&&(n=e.level,null!=e.position?t=yt(e.position,a,i):null!=e.renderedPosition&&(t=e.renderedPosition),null==t||r.panningEnabled||(o=!0)),n=(n=n>r.maxZoom?r.maxZoom:n)t.maxZoom||!t.zoomingEnabled?a=!0:(t.zoom=s,i.push("zoom"))}if(r&&(!a||!e.cancelOnFailedZoom)&&t.panningEnabled){var l=e.pan;x(l.x)&&(t.pan.x=l.x,o=!1),x(l.y)&&(t.pan.y=l.y,o=!1),o||i.push("pan")}return i.length>0&&(i.push("viewport"),this.emit(i.join(" ")),this.notify("viewport")),this},center:function(e){var t=this.getCenterPan(e);return t&&(this._private.pan=t,this.emit("pan viewport"),this.notify("viewport")),this},getCenterPan:function(e,t){if(this._private.panningEnabled){if(v(e)){var n=e;e=this.mutableElements().filter(n)}else E(e)||(e=this.mutableElements());if(0!==e.length){var r=e.boundingBox(),i=this.width(),a=this.height();return{x:(i-(t=void 0===t?this._private.zoom:t)*(r.x1+r.x2))/2,y:(a-t*(r.y1+r.y2))/2}}}},reset:function(){return this._private.panningEnabled&&this._private.zoomingEnabled?(this.viewport({pan:{x:0,y:0},zoom:1}),this):this},invalidateSize:function(){this._private.sizeCache=null},size:function(){var e,t,n=this._private,r=n.container,i=this;return n.sizeCache=n.sizeCache||(r?(e=i.window().getComputedStyle(r),t=function(t){return parseFloat(e.getPropertyValue(t))},{width:r.clientWidth-t("padding-left")-t("padding-right"),height:r.clientHeight-t("padding-top")-t("padding-bottom")}):{width:1,height:1})},width:function(){return this.size().width},height:function(){return this.size().height},extent:function(){var e=this._private.pan,t=this._private.zoom,n=this.renderedExtent(),r={x1:(n.x1-e.x)/t,x2:(n.x2-e.x)/t,y1:(n.y1-e.y)/t,y2:(n.y2-e.y)/t};return r.w=r.x2-r.x1,r.h=r.y2-r.y1,r},renderedExtent:function(){var e=this.width(),t=this.height();return{x1:0,y1:0,x2:e,y2:t,w:e,h:t}},multiClickDebounceTime:function(e){return e?(this._private.multiClickDebounceTime=e,this):this._private.multiClickDebounceTime}};As.centre=As.center,As.autolockNodes=As.autolock,As.autoungrabifyNodes=As.autoungrabify;var Ls={data:Fi.data({field:"data",bindingEvent:"data",allowBinding:!0,allowSetting:!0,settingEvent:"data",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeData:Fi.removeData({field:"data",event:"data",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0}),scratch:Fi.data({field:"scratch",bindingEvent:"scratch",allowBinding:!0,allowSetting:!0,settingEvent:"scratch",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeScratch:Fi.removeData({field:"scratch",event:"scratch",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0})};Ls.attr=Ls.data,Ls.removeAttr=Ls.removeData;var Os=function(e){var t=this,n=(e=L({},e)).container;n&&!w(n)&&w(n[0])&&(n=n[0]);var r=n?n._cyreg:null;(r=r||{})&&r.cy&&(r.cy.destroy(),r={});var i=r.readies=r.readies||[];n&&(n._cyreg=r),r.cy=t;var a=void 0!==u&&void 0!==n&&!e.headless,o=e;o.layout=L({name:a?"grid":"null"},o.layout),o.renderer=L({name:a?"canvas":"null"},o.renderer);var s=function(e,t,n){return void 0!==t?t:void 0!==n?n:e},l=this._private={container:n,ready:!1,options:o,elements:new ts(this),listeners:[],aniEles:new ts(this),data:o.data||{},scratch:{},layout:null,renderer:null,destroyed:!1,notificationsEnabled:!0,minZoom:1e-50,maxZoom:1e50,zoomingEnabled:s(!0,o.zoomingEnabled),userZoomingEnabled:s(!0,o.userZoomingEnabled),panningEnabled:s(!0,o.panningEnabled),userPanningEnabled:s(!0,o.userPanningEnabled),boxSelectionEnabled:s(!0,o.boxSelectionEnabled),autolock:s(!1,o.autolock,o.autolockNodes),autoungrabify:s(!1,o.autoungrabify,o.autoungrabifyNodes),autounselectify:s(!1,o.autounselectify),styleEnabled:void 0===o.styleEnabled?a:o.styleEnabled,zoom:x(o.zoom)?o.zoom:1,pan:{x:b(o.pan)&&x(o.pan.x)?o.pan.x:0,y:b(o.pan)&&x(o.pan.y)?o.pan.y:0},animation:{current:[],queue:[]},hasCompoundNodes:!1,multiClickDebounceTime:s(250,o.multiClickDebounceTime)};this.createEmitter(),this.selectionType(o.selectionType),this.zoomRange({min:o.minZoom,max:o.maxZoom});l.styleEnabled&&t.setStyle([]);var c=L({},o,o.renderer);t.initRenderer(c);!function(e,t){if(e.some(T))return vr.all(e).then(t);t(e)}([o.style,o.elements],(function(e){var n=e[0],a=e[1];l.styleEnabled&&t.style().append(n),function(e,n,r){t.notifications(!1);var i=t.mutableElements();i.length>0&&i.remove(),null!=e&&(b(e)||m(e))&&t.add(e),t.one("layoutready",(function(e){t.notifications(!0),t.emit(e),t.one("load",n),t.emitAndNotify("load")})).one("layoutstop",(function(){t.one("done",r),t.emit("done")}));var a=L({},t._private.options.layout);a.eles=t.elements(),t.layout(a).run()}(a,(function(){t.startAnimationLoop(),l.ready=!0,y(o.ready)&&t.on("ready",o.ready);for(var e=0;e0,u=_t(n.boundingBox?n.boundingBox:{x1:0,y1:0,w:r.width(),h:r.height()});if(E(n.roots))e=n.roots;else if(m(n.roots)){for(var c=[],d=0;d0;){var N=_.shift(),z=T(N,M);if(z)N.outgoers().filter((function(e){return e.isNode()&&i.has(e)})).forEach(B);else if(null===z){je("Detected double maximal shift for node `"+N.id()+"`. Bailing maximal adjustment due to cycle. Use `options.maximal: true` only on DAGs.");break}}}D();var I=0;if(n.avoidOverlap)for(var L=0;L0&&b[0].length<=3?l/2:0),d=2*Math.PI/b[r].length*i;return 0===r&&1===b[0].length&&(c=1),{x:G+c*Math.cos(d),y:U+c*Math.sin(d)}}return{x:G+(i+1-(a+1)/2)*o,y:(r+1)*s}})),this};var Xs={fit:!0,padding:30,boundingBox:void 0,avoidOverlap:!0,nodeDimensionsIncludeLabels:!1,spacingFactor:void 0,radius:void 0,startAngle:1.5*Math.PI,sweep:void 0,clockwise:!0,sort:void 0,animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:function(e,t){return!0},ready:void 0,stop:void 0,transform:function(e,t){return t}};function Ws(e){this.options=L({},Xs,e)}Ws.prototype.run=function(){var e=this.options,t=e,n=e.cy,r=t.eles,i=void 0!==t.counterclockwise?!t.counterclockwise:t.clockwise,a=r.nodes().not(":parent");t.sort&&(a=a.sort(t.sort));for(var o,s=_t(t.boundingBox?t.boundingBox:{x1:0,y1:0,w:n.width(),h:n.height()}),l=s.x1+s.w/2,u=s.y1+s.h/2,c=(void 0===t.sweep?2*Math.PI-2*Math.PI/a.length:t.sweep)/Math.max(1,a.length-1),d=0,h=0;h1&&t.avoidOverlap){d*=1.75;var v=Math.cos(c)-Math.cos(0),y=Math.sin(c)-Math.sin(0),m=Math.sqrt(d*d/(v*v+y*y));o=Math.max(m,o)}return r.nodes().layoutPositions(this,t,(function(e,n){var r=t.startAngle+n*c*(i?1:-1),a=o*Math.cos(r),s=o*Math.sin(r);return{x:l+a,y:u+s}})),this};var Hs,Ks={fit:!0,padding:30,startAngle:1.5*Math.PI,sweep:void 0,clockwise:!0,equidistant:!1,minNodeSpacing:10,boundingBox:void 0,avoidOverlap:!0,nodeDimensionsIncludeLabels:!1,height:void 0,width:void 0,spacingFactor:void 0,concentric:function(e){return e.degree()},levelWidth:function(e){return e.maxDegree()/4},animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:function(e,t){return!0},ready:void 0,stop:void 0,transform:function(e,t){return t}};function Gs(e){this.options=L({},Ks,e)}Gs.prototype.run=function(){for(var e=this.options,t=e,n=void 0!==t.counterclockwise?!t.counterclockwise:t.clockwise,r=e.cy,i=t.eles,a=i.nodes().not(":parent"),o=_t(t.boundingBox?t.boundingBox:{x1:0,y1:0,w:r.width(),h:r.height()}),s=o.x1+o.w/2,l=o.y1+o.h/2,u=[],c=0,d=0;d0)Math.abs(m[0].value-x.value)>=v&&(m=[],y.push(m));m.push(x)}var w=c+t.minNodeSpacing;if(!t.avoidOverlap){var E=y.length>0&&y[0].length>1,k=(Math.min(o.w,o.h)/2-w)/(y.length+E?1:0);w=Math.min(w,k)}for(var C=0,S=0;S1&&t.avoidOverlap){var _=Math.cos(T)-Math.cos(0),M=Math.sin(T)-Math.sin(0),B=Math.sqrt(w*w/(_*_+M*M));C=Math.max(B,C)}P.r=C,C+=w}if(t.equidistant){for(var N=0,z=0,I=0;I=e.numIter)&&(rl(r,e),r.temperature=r.temperature*e.coolingFactor,!(r.temperature=e.animationThreshold&&a(),xe(t)):(gl(r,e),s())}()}else{for(;u;)u=o(l),l++;gl(r,e),s()}return this},Zs.prototype.stop=function(){return this.stopped=!0,this.thread&&this.thread.stop(),this.emit("layoutstop"),this},Zs.prototype.destroy=function(){return this.thread&&this.thread.stop(),this};var $s=function(e,t,n){for(var r=n.eles.edges(),i=n.eles.nodes(),a=_t(n.boundingBox?n.boundingBox:{x1:0,y1:0,w:e.width(),h:e.height()}),o={isCompound:e.hasCompoundNodes(),layoutNodes:[],idToIndex:{},nodeSize:i.size(),graphSet:[],indexToGraph:[],layoutEdges:[],edgeSize:r.size(),temperature:n.initialTemp,clientWidth:a.w,clientHeight:a.h,boundingBox:a},s=n.eles.components(),l={},u=0;u0){o.graphSet.push(E);for(u=0;ur.count?0:r.graph},Js=function e(t,n,r,i){var a=i.graphSet[r];if(-10)var s=(u=r.nodeOverlap*o)*i/(g=Math.sqrt(i*i+a*a)),l=u*a/g;else{var u,c=ll(e,i,a),d=ll(t,-1*i,-1*a),h=d.x-c.x,p=d.y-c.y,f=h*h+p*p,g=Math.sqrt(f);s=(u=(e.nodeRepulsion+t.nodeRepulsion)/f)*h/g,l=u*p/g}e.isLocked||(e.offsetX-=s,e.offsetY-=l),t.isLocked||(t.offsetX+=s,t.offsetY+=l)}},sl=function(e,t,n,r){if(n>0)var i=e.maxX-t.minX;else i=t.maxX-e.minX;if(r>0)var a=e.maxY-t.minY;else a=t.maxY-e.minY;return i>=0&&a>=0?Math.sqrt(i*i+a*a):0},ll=function(e,t,n){var r=e.positionX,i=e.positionY,a=e.height||1,o=e.width||1,s=n/t,l=a/o,u={};return 0===t&&0n?(u.x=r,u.y=i+a/2,u):0t&&-1*l<=s&&s<=l?(u.x=r-o/2,u.y=i-o*n/2/t,u):0=l)?(u.x=r+a*t/2/n,u.y=i+a/2,u):0>n&&(s<=-1*l||s>=l)?(u.x=r-a*t/2/n,u.y=i-a/2,u):u},ul=function(e,t){for(var n=0;n1){var f=t.gravity*d/p,g=t.gravity*h/p;c.offsetX+=f,c.offsetY+=g}}}}},dl=function(e,t){var n=[],r=0,i=-1;for(n.push.apply(n,e.graphSet[0]),i+=e.graphSet[0].length;r<=i;){var a=n[r++],o=e.idToIndex[a],s=e.layoutNodes[o],l=s.children;if(0n)var i={x:n*e/r,y:n*t/r};else i={x:e,y:t};return i},fl=function e(t,n){var r=t.parentId;if(null!=r){var i=n.layoutNodes[n.idToIndex[r]],a=!1;return(null==i.maxX||t.maxX+i.padRight>i.maxX)&&(i.maxX=t.maxX+i.padRight,a=!0),(null==i.minX||t.minX-i.padLefti.maxY)&&(i.maxY=t.maxY+i.padBottom,a=!0),(null==i.minY||t.minY-i.padTopf&&(d+=p+t.componentSpacing,c=0,h=0,p=0)}}},vl={fit:!0,padding:30,boundingBox:void 0,avoidOverlap:!0,avoidOverlapPadding:10,nodeDimensionsIncludeLabels:!1,spacingFactor:void 0,condense:!1,rows:void 0,cols:void 0,position:function(e){},sort:void 0,animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:function(e,t){return!0},ready:void 0,stop:void 0,transform:function(e,t){return t}};function yl(e){this.options=L({},vl,e)}yl.prototype.run=function(){var e=this.options,t=e,n=e.cy,r=t.eles,i=r.nodes().not(":parent");t.sort&&(i=i.sort(t.sort));var a=_t(t.boundingBox?t.boundingBox:{x1:0,y1:0,w:n.width(),h:n.height()});if(0===a.h||0===a.w)r.nodes().layoutPositions(this,t,(function(e){return{x:a.x1,y:a.y1}}));else{var o=i.size(),s=Math.sqrt(o*a.h/a.w),l=Math.round(s),u=Math.round(a.w/a.h*s),c=function(e){if(null==e)return Math.min(l,u);Math.min(l,u)==l?l=e:u=e},d=function(e){if(null==e)return Math.max(l,u);Math.max(l,u)==l?l=e:u=e},h=t.rows,p=null!=t.cols?t.cols:t.columns;if(null!=h&&null!=p)l=h,u=p;else if(null!=h&&null==p)l=h,u=Math.ceil(o/l);else if(null==h&&null!=p)u=p,l=Math.ceil(o/u);else if(u*l>o){var f=c(),g=d();(f-1)*g>=o?c(f-1):(g-1)*f>=o&&d(g-1)}else for(;u*l=o?d(y+1):c(v+1)}var m=a.w/u,b=a.h/l;if(t.condense&&(m=0,b=0),t.avoidOverlap)for(var x=0;x=u&&(B=0,M++)},z={},I=0;I(r=qt(e,t,x[w],x[w+1],x[w+2],x[w+3])))return v(n,r),!0}else if("bezier"===a.edgeType||"multibezier"===a.edgeType||"self"===a.edgeType||"compound"===a.edgeType)for(x=a.allpts,w=0;w+5(r=jt(e,t,x[w],x[w+1],x[w+2],x[w+3],x[w+4],x[w+5])))return v(n,r),!0;m=m||i.source,b=b||i.target;var E=o.getArrowWidth(l,c),k=[{name:"source",x:a.arrowStartX,y:a.arrowStartY,angle:a.srcArrowAngle},{name:"target",x:a.arrowEndX,y:a.arrowEndY,angle:a.tgtArrowAngle},{name:"mid-source",x:a.midX,y:a.midY,angle:a.midsrcArrowAngle},{name:"mid-target",x:a.midX,y:a.midY,angle:a.midtgtArrowAngle}];for(w=0;w0&&(y(m),y(b))}function b(e,t,n){return Ue(e,t,n)}function x(n,r){var i,a=n._private,o=f;i=r?r+"-":"",n.boundingBox();var s=a.labelBounds[r||"main"],l=n.pstyle(i+"label").value;if("yes"===n.pstyle("text-events").strValue&&l){var u=b(a.rscratch,"labelX",r),c=b(a.rscratch,"labelY",r),d=b(a.rscratch,"labelAngle",r),h=n.pstyle(i+"text-margin-x").pfValue,p=n.pstyle(i+"text-margin-y").pfValue,g=s.x1-o-h,y=s.x2+o-h,m=s.y1-o-p,x=s.y2+o-p;if(d){var w=Math.cos(d),E=Math.sin(d),k=function(e,t){return{x:(e-=u)*w-(t-=c)*E+u,y:e*E+t*w+c}},C=k(g,m),S=k(g,x),P=k(y,m),D=k(y,x),T=[C.x+h,C.y+p,P.x+h,P.y+p,D.x+h,D.y+p,S.x+h,S.y+p];if(Yt(e,t,T))return v(n),!0}else if(Lt(s,e,t))return v(n),!0}}n&&(l=l.interactive);for(var w=l.length-1;w>=0;w--){var E=l[w];E.isNode()?y(E)||x(E):m(E)||x(E)||x(E,"source")||x(E,"target")}return u},getAllInBox:function(e,t,n,r){for(var i,a,o=this.getCachedZSortedEles().interactive,s=[],l=Math.min(e,n),u=Math.max(e,n),c=Math.min(t,r),d=Math.max(t,r),h=_t({x1:e=l,y1:t=c,x2:n=u,y2:r=d}),p=0;p0?-(Math.PI-a.ang):Math.PI+a.ang),Zl(t,n,Ul),zl=Gl.nx*Ul.ny-Gl.ny*Ul.nx,Il=Gl.nx*Ul.nx-Gl.ny*-Ul.ny,Ol=Math.asin(Math.max(-1,Math.min(1,zl))),Math.abs(Ol)<1e-6)return Bl=t.x,Nl=t.y,void(Vl=jl=0);Al=1,Ll=!1,Il<0?Ol<0?Ol=Math.PI+Ol:(Ol=Math.PI-Ol,Al=-1,Ll=!0):Ol>0&&(Al=-1,Ll=!0),jl=void 0!==t.radius?t.radius:r,Rl=Ol/2,ql=Math.min(Gl.len/2,Ul.len/2),i?(Fl=Math.abs(Math.cos(Rl)*jl/Math.sin(Rl)))>ql?(Fl=ql,Vl=Math.abs(Fl*Math.sin(Rl)/Math.cos(Rl))):Vl=jl:(Fl=Math.min(ql,jl),Vl=Math.abs(Fl*Math.sin(Rl)/Math.cos(Rl))),Wl=t.x+Ul.nx*Fl,Hl=t.y+Ul.ny*Fl,Bl=Wl-Ul.ny*Vl*Al,Nl=Hl+Ul.nx*Vl*Al,Yl=t.x+Gl.nx*Fl,Xl=t.y+Gl.ny*Fl,Kl=t};function Ql(e,t){0===t.radius?e.lineTo(t.cx,t.cy):e.arc(t.cx,t.cy,t.radius,t.startAngle,t.endAngle,t.counterClockwise)}function Jl(e,t,n,r){var i=!(arguments.length>4&&void 0!==arguments[4])||arguments[4];return 0===r||0===t.radius?{cx:t.x,cy:t.y,radius:0,startX:t.x,startY:t.y,stopX:t.x,stopY:t.y,startAngle:void 0,endAngle:void 0,counterClockwise:void 0}:($l(e,t,n,r,i),{cx:Bl,cy:Nl,radius:Vl,startX:Yl,startY:Xl,stopX:Wl,stopY:Hl,startAngle:Gl.ang+Math.PI/2*Al,endAngle:Ul.ang-Math.PI/2*Al,counterClockwise:Ll})}var eu={};function tu(e){var t=[];if(null!=e){for(var n=0;n0?Math.max(e-t,0):Math.min(e+t,0)},w=x(m,v),E=x(b,y),k=!1;"auto"===c?u=Math.abs(w)>Math.abs(E)?"horizontal":"vertical":"upward"===c||"downward"===c?(u="vertical",k=!0):"leftward"!==c&&"rightward"!==c||(u="horizontal",k=!0);var C,S="vertical"===u,P=S?E:w,D=S?b:m,T=Et(D),_=!1;(k&&(h||f)||!("downward"===c&&D<0||"upward"===c&&D>0||"leftward"===c&&D>0||"rightward"===c&&D<0)||(P=(T*=-1)*Math.abs(P),_=!0),h)?C=(p<0?1+p:p)*P:C=(p<0?P:0)+p*T;var M=function(e){return Math.abs(e)=Math.abs(P)},B=M(C),N=M(Math.abs(P)-Math.abs(C));if((B||N)&&!_)if(S){var z=Math.abs(D)<=a/2,I=Math.abs(m)<=o/2;if(z){var A=(r.x1+r.x2)/2,L=r.y1,O=r.y2;n.segpts=[A,L,A,O]}else if(I){var R=(r.y1+r.y2)/2,V=r.x1,F=r.x2;n.segpts=[V,R,F,R]}else n.segpts=[r.x1,r.y2]}else{var j=Math.abs(D)<=i/2,q=Math.abs(b)<=s/2;if(j){var Y=(r.y1+r.y2)/2,X=r.x1,W=r.x2;n.segpts=[X,Y,W,Y]}else if(q){var H=(r.x1+r.x2)/2,K=r.y1,G=r.y2;n.segpts=[H,K,H,G]}else n.segpts=[r.x2,r.y1]}else if(S){var U=r.y1+C+(l?a/2*T:0),Z=r.x1,$=r.x2;n.segpts=[Z,U,$,U]}else{var Q=r.x1+C+(l?i/2*T:0),J=r.y1,ee=r.y2;n.segpts=[Q,J,Q,ee]}if(n.isRound){var te=e.pstyle("taxi-radius").value,ne="arc-radius"===e.pstyle("radius-type").value[0];n.radii=new Array(n.segpts.length/2).fill(te),n.isArcRadius=new Array(n.segpts.length/2).fill(ne)}},eu.tryToCorrectInvalidPoints=function(e,t){var n=e._private.rscratch;if("bezier"===n.edgeType){var r=t.srcPos,i=t.tgtPos,a=t.srcW,o=t.srcH,s=t.tgtW,l=t.tgtH,u=t.srcShape,c=t.tgtShape,d=t.srcCornerRadius,h=t.tgtCornerRadius,p=t.srcRs,f=t.tgtRs,g=!x(n.startX)||!x(n.startY),v=!x(n.arrowStartX)||!x(n.arrowStartY),y=!x(n.endX)||!x(n.endY),m=!x(n.arrowEndX)||!x(n.arrowEndY),b=3*(this.getArrowWidth(e.pstyle("width").pfValue,e.pstyle("arrow-scale").value)*this.arrowShapeWidth),w=kt({x:n.ctrlpts[0],y:n.ctrlpts[1]},{x:n.startX,y:n.startY}),E=wh.poolIndex()){var p=d;d=h,h=p}var f=s.srcPos=d.position(),g=s.tgtPos=h.position(),v=s.srcW=d.outerWidth(),y=s.srcH=d.outerHeight(),m=s.tgtW=h.outerWidth(),b=s.tgtH=h.outerHeight(),w=s.srcShape=n.nodeShapes[t.getNodeShape(d)],E=s.tgtShape=n.nodeShapes[t.getNodeShape(h)],k=s.srcCornerRadius="auto"===d.pstyle("corner-radius").value?"auto":d.pstyle("corner-radius").pfValue,C=s.tgtCornerRadius="auto"===h.pstyle("corner-radius").value?"auto":h.pstyle("corner-radius").pfValue,S=s.tgtRs=h._private.rscratch,P=s.srcRs=d._private.rscratch;s.dirCounts={north:0,west:0,south:0,east:0,northwest:0,southwest:0,northeast:0,southeast:0};for(var D=0;D0){var H=u,K=Ct(H,bt(t)),G=Ct(H,bt(W)),U=K;if(G2)Ct(H,{x:W[2],y:W[3]})0){var le=c,ue=Ct(le,bt(t)),ce=Ct(le,bt(se)),de=ue;if(ce2)Ct(le,{x:se[2],y:se[3]})=c||b){d={cp:v,segment:m};break}}if(d)break}var x=d.cp,w=d.segment,E=(c-p)/w.length,k=w.t1-w.t0,C=u?w.t0+k*E:w.t1-k*E;C=Tt(0,C,1),t=Dt(x.p0,x.p1,x.p2,C),l=function(e,t,n,r){var i=Tt(0,r-.001,1),a=Tt(0,r+.001,1),o=Dt(e,t,n,i),s=Dt(e,t,n,a);return su(o,s)}(x.p0,x.p1,x.p2,C);break;case"straight":case"segments":case"haystack":for(var S,P,D,T,_=0,M=r.allpts.length,B=0;B+3=c));B+=2);var N=(c-P)/S;N=Tt(0,N,1),t=function(e,t,n,r){var i=t.x-e.x,a=t.y-e.y,o=kt(e,t),s=i/o,l=a/o;return n=null==n?0:n,r=null!=r?r:n*o,{x:e.x+s*r,y:e.y+l*r}}(D,T,N),l=su(D,T)}o("labelX",s,t.x),o("labelY",s,t.y),o("labelAutoAngle",s,l)}};l("source"),l("target"),this.applyLabelDimensions(e)}},au.applyLabelDimensions=function(e){this.applyPrefixedLabelDimensions(e),e.isEdge()&&(this.applyPrefixedLabelDimensions(e,"source"),this.applyPrefixedLabelDimensions(e,"target"))},au.applyPrefixedLabelDimensions=function(e,t){var n=e._private,r=this.getLabelText(e,t),i=this.calculateLabelDimensions(e,r),a=e.pstyle("line-height").pfValue,o=e.pstyle("text-wrap").strValue,s=Ue(n.rscratch,"labelWrapCachedLines",t)||[],l="wrap"!==o?1:Math.max(s.length,1),u=i.height/l,c=u*a,d=i.width,h=i.height+(l-1)*(a-1)*u;Ze(n.rstyle,"labelWidth",t,d),Ze(n.rscratch,"labelWidth",t,d),Ze(n.rstyle,"labelHeight",t,h),Ze(n.rscratch,"labelHeight",t,h),Ze(n.rscratch,"labelLineHeight",t,c)},au.getLabelText=function(e,t){var n=e._private,r=t?t+"-":"",i=e.pstyle(r+"label").strValue,a=e.pstyle("text-transform").value,o=function(e,r){return r?(Ze(n.rscratch,e,t,r),r):Ue(n.rscratch,e,t)};if(!i)return"";"none"==a||("uppercase"==a?i=i.toUpperCase():"lowercase"==a&&(i=i.toLowerCase()));var s=e.pstyle("text-wrap").value;if("wrap"===s){var u=o("labelKey");if(null!=u&&o("labelWrapKey")===u)return o("labelWrapCachedText");for(var c=i.split("\n"),d=e.pstyle("text-max-width").pfValue,h="anywhere"===e.pstyle("text-overflow-wrap").value,p=[],f=/[\s\u200b]+|$/g,g=0;gd){var b,x="",w=0,E=l(v.matchAll(f));try{for(E.s();!(b=E.n()).done;){var k=b.value,C=k[0],S=v.substring(w,k.index);w=k.index+C.length;var P=0===x.length?S:x+S+C;this.calculateLabelDimensions(e,P).width<=d?x+=S+C:(x&&p.push(x),x=S+C)}}catch(e){E.e(e)}finally{E.f()}x.match(/^[\s\u200b]+$/)||p.push(x)}else p.push(v)}o("labelWrapCachedLines",p),i=o("labelWrapCachedText",p.join("\n")),o("labelWrapKey",u)}else if("ellipsis"===s){var D=e.pstyle("text-max-width").pfValue,T="",_=!1;if(this.calculateLabelDimensions(e,i).widthD)break;T+=i[M],M===i.length-1&&(_=!0)}return _||(T+="…"),T}return i},au.getLabelJustification=function(e){var t=e.pstyle("text-justification").strValue,n=e.pstyle("text-halign").strValue;if("auto"!==t)return t;if(!e.isNode())return"center";switch(n){case"left":return"right";case"right":return"left";default:return"center"}},au.calculateLabelDimensions=function(e,t){var n=this,r=n.cy.window().document,i=Te(t,e._private.labelDimsKey),a=n.labelDimCache||(n.labelDimCache=[]),o=a[i];if(null!=o)return o;var s=e.pstyle("font-style").strValue,l=e.pstyle("font-size").pfValue,u=e.pstyle("font-family").strValue,c=e.pstyle("font-weight").strValue,d=this.labelCalcCanvas,h=this.labelCalcCanvasContext;if(!d){d=this.labelCalcCanvas=r.createElement("canvas"),h=this.labelCalcCanvasContext=d.getContext("2d");var p=d.style;p.position="absolute",p.left="-9999px",p.top="-9999px",p.zIndex="-1",p.visibility="hidden",p.pointerEvents="none"}h.font="".concat(s," ").concat(c," ").concat(l,"px ").concat(u);for(var f=0,g=0,v=t.split("\n"),y=0;y1&&void 0!==arguments[1])||arguments[1];if(t.merge(e),n)for(var r=0;r=e.desktopTapThreshold2}var D=i(t);v&&(e.hoverData.tapholdCancelled=!0);n=!0,r(g,["mousemove","vmousemove","tapdrag"],t,{x:c[0],y:c[1]});var T=function(){e.data.bgActivePosistion=void 0,e.hoverData.selecting||o.emit({originalEvent:t,type:"boxstart",position:{x:c[0],y:c[1]}}),f[4]=1,e.hoverData.selecting=!0,e.redrawHint("select",!0),e.redraw()};if(3===e.hoverData.which){if(v){var _={originalEvent:t,type:"cxtdrag",position:{x:c[0],y:c[1]}};b?b.emit(_):o.emit(_),e.hoverData.cxtDragged=!0,e.hoverData.cxtOver&&g===e.hoverData.cxtOver||(e.hoverData.cxtOver&&e.hoverData.cxtOver.emit({originalEvent:t,type:"cxtdragout",position:{x:c[0],y:c[1]}}),e.hoverData.cxtOver=g,g&&g.emit({originalEvent:t,type:"cxtdragover",position:{x:c[0],y:c[1]}}))}}else if(e.hoverData.dragging){if(n=!0,o.panningEnabled()&&o.userPanningEnabled()){var M;if(e.hoverData.justStartedPan){var B=e.hoverData.mdownPos;M={x:(c[0]-B[0])*s,y:(c[1]-B[1])*s},e.hoverData.justStartedPan=!1}else M={x:w[0]*s,y:w[1]*s};o.panBy(M),o.emit("dragpan"),e.hoverData.dragged=!0}c=e.projectIntoViewport(t.clientX,t.clientY)}else if(1!=f[4]||null!=b&&!b.pannable()){if(b&&b.pannable()&&b.active()&&b.unactivate(),b&&b.grabbed()||g==y||(y&&r(y,["mouseout","tapdragout"],t,{x:c[0],y:c[1]}),g&&r(g,["mouseover","tapdragover"],t,{x:c[0],y:c[1]}),e.hoverData.last=g),b)if(v){if(o.boxSelectionEnabled()&&D)b&&b.grabbed()&&(d(E),b.emit("freeon"),E.emit("free"),e.dragData.didDrag&&(b.emit("dragfreeon"),E.emit("dragfree"))),T();else if(b&&b.grabbed()&&e.nodeIsDraggable(b)){var N=!e.dragData.didDrag;N&&e.redrawHint("eles",!0),e.dragData.didDrag=!0,e.hoverData.draggingEles||u(E,{inDragLayer:!0});var z={x:0,y:0};if(x(w[0])&&x(w[1])&&(z.x+=w[0],z.y+=w[1],N)){var I=e.hoverData.dragDelta;I&&x(I[0])&&x(I[1])&&(z.x+=I[0],z.y+=I[1])}e.hoverData.draggingEles=!0,E.silentShift(z).emit("position drag"),e.redrawHint("drag",!0),e.redraw()}}else!function(){var t=e.hoverData.dragDelta=e.hoverData.dragDelta||[];0===t.length?(t.push(w[0]),t.push(w[1])):(t[0]+=w[0],t[1]+=w[1])}();n=!0}else if(v){if(e.hoverData.dragging||!o.boxSelectionEnabled()||!D&&o.panningEnabled()&&o.userPanningEnabled()){if(!e.hoverData.selecting&&o.panningEnabled()&&o.userPanningEnabled()){a(b,e.hoverData.downs)&&(e.hoverData.dragging=!0,e.hoverData.justStartedPan=!0,f[4]=0,e.data.bgActivePosistion=bt(h),e.redrawHint("select",!0),e.redraw())}}else T();b&&b.pannable()&&b.active()&&b.unactivate()}return f[2]=c[0],f[3]=c[1],n?(t.stopPropagation&&t.stopPropagation(),t.preventDefault&&t.preventDefault(),!1):void 0}}),!1),e.registerBinding(t,"mouseup",(function(t){if((1!==e.hoverData.which||1===t.which||!e.hoverData.capture)&&e.hoverData.capture){e.hoverData.capture=!1;var a=e.cy,o=e.projectIntoViewport(t.clientX,t.clientY),s=e.selection,l=e.findNearestElement(o[0],o[1],!0,!1),u=e.dragData.possibleDragElements,c=e.hoverData.down,h=i(t);if(e.data.bgActivePosistion&&(e.redrawHint("select",!0),e.redraw()),e.hoverData.tapholdCancelled=!0,e.data.bgActivePosistion=void 0,c&&c.unactivate(),3===e.hoverData.which){var p={originalEvent:t,type:"cxttapend",position:{x:o[0],y:o[1]}};if(c?c.emit(p):a.emit(p),!e.hoverData.cxtDragged){var f={originalEvent:t,type:"cxttap",position:{x:o[0],y:o[1]}};c?c.emit(f):a.emit(f)}e.hoverData.cxtDragged=!1,e.hoverData.which=null}else if(1===e.hoverData.which){if(r(l,["mouseup","tapend","vmouseup"],t,{x:o[0],y:o[1]}),e.dragData.didDrag||e.hoverData.dragged||e.hoverData.selecting||e.hoverData.isOverThresholdDrag||(r(c,["click","tap","vclick"],t,{x:o[0],y:o[1]}),w=!1,t.timeStamp-E<=a.multiClickDebounceTime()?(b&&clearTimeout(b),w=!0,E=null,r(c,["dblclick","dbltap","vdblclick"],t,{x:o[0],y:o[1]})):(b=setTimeout((function(){w||r(c,["oneclick","onetap","voneclick"],t,{x:o[0],y:o[1]})}),a.multiClickDebounceTime()),E=t.timeStamp)),null!=c||e.dragData.didDrag||e.hoverData.selecting||e.hoverData.dragged||i(t)||(a.$(n).unselect(["tapunselect"]),u.length>0&&e.redrawHint("eles",!0),e.dragData.possibleDragElements=u=a.collection()),l!=c||e.dragData.didDrag||e.hoverData.selecting||null!=l&&l._private.selectable&&(e.hoverData.dragging||("additive"===a.selectionType()||h?l.selected()?l.unselect(["tapunselect"]):l.select(["tapselect"]):h||(a.$(n).unmerge(l).unselect(["tapunselect"]),l.select(["tapselect"]))),e.redrawHint("eles",!0)),e.hoverData.selecting){var g=a.collection(e.getAllInBox(s[0],s[1],s[2],s[3]));e.redrawHint("select",!0),g.length>0&&e.redrawHint("eles",!0),a.emit({type:"boxend",originalEvent:t,position:{x:o[0],y:o[1]}});var v=function(e){return e.selectable()&&!e.selected()};"additive"===a.selectionType()||h||a.$(n).unmerge(g).unselect(),g.emit("box").stdFilter(v).select().emit("boxselect"),e.redraw()}if(e.hoverData.dragging&&(e.hoverData.dragging=!1,e.redrawHint("select",!0),e.redrawHint("eles",!0),e.redraw()),!s[4]){e.redrawHint("drag",!0),e.redrawHint("eles",!0);var y=c&&c.grabbed();d(u),y&&(c.emit("freeon"),u.emit("free"),e.dragData.didDrag&&(c.emit("dragfreeon"),u.emit("dragfree")))}}s[4]=0,e.hoverData.down=null,e.hoverData.cxtStarted=!1,e.hoverData.draggingEles=!1,e.hoverData.selecting=!1,e.hoverData.isOverThresholdDrag=!1,e.dragData.didDrag=!1,e.hoverData.dragged=!1,e.hoverData.dragDelta=[],e.hoverData.mdownPos=null,e.hoverData.mdownGPos=null,e.hoverData.which=null}}),!1);var C,S,P,D,T,_,M,B,N,z,I,A,L,O=function(t){if(!e.scrollingPage){var n=e.cy,r=n.zoom(),i=n.pan(),a=e.projectIntoViewport(t.clientX,t.clientY),o=[a[0]*r+i.x,a[1]*r+i.y];if(e.hoverData.draggingEles||e.hoverData.dragging||e.hoverData.cxtStarted||0!==e.selection[4])t.preventDefault();else if(n.panningEnabled()&&n.userPanningEnabled()&&n.zoomingEnabled()&&n.userZoomingEnabled()){var s;t.preventDefault(),e.data.wheelZooming=!0,clearTimeout(e.data.wheelTimeout),e.data.wheelTimeout=setTimeout((function(){e.data.wheelZooming=!1,e.redrawHint("eles",!0),e.redraw()}),150),s=null!=t.deltaY?t.deltaY/-250:null!=t.wheelDeltaY?t.wheelDeltaY/1e3:t.wheelDelta/1e3,s*=e.wheelSensitivity,1===t.deltaMode&&(s*=33);var l=n.zoom()*Math.pow(10,s);"gesturechange"===t.type&&(l=e.gestureStartZoom*t.scale),n.zoom({level:l,renderedPosition:{x:o[0],y:o[1]}}),n.emit("gesturechange"===t.type?"pinchzoom":"scrollzoom")}}};e.registerBinding(e.container,"wheel",O,!0),e.registerBinding(t,"scroll",(function(t){e.scrollingPage=!0,clearTimeout(e.scrollingPageTimeout),e.scrollingPageTimeout=setTimeout((function(){e.scrollingPage=!1}),250)}),!0),e.registerBinding(e.container,"gesturestart",(function(t){e.gestureStartZoom=e.cy.zoom(),e.hasTouchStarted||t.preventDefault()}),!0),e.registerBinding(e.container,"gesturechange",(function(t){e.hasTouchStarted||O(t)}),!0),e.registerBinding(e.container,"mouseout",(function(t){var n=e.projectIntoViewport(t.clientX,t.clientY);e.cy.emit({originalEvent:t,type:"mouseout",position:{x:n[0],y:n[1]}})}),!1),e.registerBinding(e.container,"mouseover",(function(t){var n=e.projectIntoViewport(t.clientX,t.clientY);e.cy.emit({originalEvent:t,type:"mouseover",position:{x:n[0],y:n[1]}})}),!1);var R,V,F,j,q,Y,X,W=function(e,t,n,r){return Math.sqrt((n-e)*(n-e)+(r-t)*(r-t))},H=function(e,t,n,r){return(n-e)*(n-e)+(r-t)*(r-t)};if(e.registerBinding(e.container,"touchstart",R=function(t){if(e.hasTouchStarted=!0,m(t)){p(),e.touchData.capture=!0,e.data.bgActivePosistion=void 0;var n=e.cy,i=e.touchData.now,a=e.touchData.earlier;if(t.touches[0]){var o=e.projectIntoViewport(t.touches[0].clientX,t.touches[0].clientY);i[0]=o[0],i[1]=o[1]}if(t.touches[1]){o=e.projectIntoViewport(t.touches[1].clientX,t.touches[1].clientY);i[2]=o[0],i[3]=o[1]}if(t.touches[2]){o=e.projectIntoViewport(t.touches[2].clientX,t.touches[2].clientY);i[4]=o[0],i[5]=o[1]}if(t.touches[1]){e.touchData.singleTouchMoved=!0,d(e.dragData.touchDragEles);var l=e.findContainerClientCoords();N=l[0],z=l[1],I=l[2],A=l[3],C=t.touches[0].clientX-N,S=t.touches[0].clientY-z,P=t.touches[1].clientX-N,D=t.touches[1].clientY-z,L=0<=C&&C<=I&&0<=P&&P<=I&&0<=S&&S<=A&&0<=D&&D<=A;var h=n.pan(),f=n.zoom();T=W(C,S,P,D),_=H(C,S,P,D),B=[((M=[(C+P)/2,(S+D)/2])[0]-h.x)/f,(M[1]-h.y)/f];if(_<4e4&&!t.touches[2]){var g=e.findNearestElement(i[0],i[1],!0,!0),v=e.findNearestElement(i[2],i[3],!0,!0);return g&&g.isNode()?(g.activate().emit({originalEvent:t,type:"cxttapstart",position:{x:i[0],y:i[1]}}),e.touchData.start=g):v&&v.isNode()?(v.activate().emit({originalEvent:t,type:"cxttapstart",position:{x:i[0],y:i[1]}}),e.touchData.start=v):n.emit({originalEvent:t,type:"cxttapstart",position:{x:i[0],y:i[1]}}),e.touchData.start&&(e.touchData.start._private.grabbed=!1),e.touchData.cxt=!0,e.touchData.cxtDragged=!1,e.data.bgActivePosistion=void 0,void e.redraw()}}if(t.touches[2])n.boxSelectionEnabled()&&t.preventDefault();else if(t.touches[1]);else if(t.touches[0]){var y=e.findNearestElements(i[0],i[1],!0,!0),b=y[0];if(null!=b&&(b.activate(),e.touchData.start=b,e.touchData.starts=y,e.nodeIsGrabbable(b))){var x=e.dragData.touchDragEles=n.collection(),w=null;e.redrawHint("eles",!0),e.redrawHint("drag",!0),b.selected()?(w=n.$((function(t){return t.selected()&&e.nodeIsGrabbable(t)})),u(w,{addToList:x})):c(b,{addToList:x}),s(b);var E=function(e){return{originalEvent:t,type:e,position:{x:i[0],y:i[1]}}};b.emit(E("grabon")),w?w.forEach((function(e){e.emit(E("grab"))})):b.emit(E("grab"))}r(b,["touchstart","tapstart","vmousedown"],t,{x:i[0],y:i[1]}),null==b&&(e.data.bgActivePosistion={x:o[0],y:o[1]},e.redrawHint("select",!0),e.redraw()),e.touchData.singleTouchMoved=!1,e.touchData.singleTouchStartTime=+new Date,clearTimeout(e.touchData.tapholdTimeout),e.touchData.tapholdTimeout=setTimeout((function(){!1!==e.touchData.singleTouchMoved||e.pinching||e.touchData.selecting||r(e.touchData.start,["taphold"],t,{x:i[0],y:i[1]})}),e.tapholdDuration)}if(t.touches.length>=1){for(var k=e.touchData.startPosition=[null,null,null,null,null,null],O=0;O=e.touchTapThreshold2}if(n&&e.touchData.cxt){t.preventDefault();var E=t.touches[0].clientX-N,k=t.touches[0].clientY-z,M=t.touches[1].clientX-N,I=t.touches[1].clientY-z,A=H(E,k,M,I);if(A/_>=2.25||A>=22500){e.touchData.cxt=!1,e.data.bgActivePosistion=void 0,e.redrawHint("select",!0);var O={originalEvent:t,type:"cxttapend",position:{x:s[0],y:s[1]}};e.touchData.start?(e.touchData.start.unactivate().emit(O),e.touchData.start=null):o.emit(O)}}if(n&&e.touchData.cxt){O={originalEvent:t,type:"cxtdrag",position:{x:s[0],y:s[1]}};e.data.bgActivePosistion=void 0,e.redrawHint("select",!0),e.touchData.start?e.touchData.start.emit(O):o.emit(O),e.touchData.start&&(e.touchData.start._private.grabbed=!1),e.touchData.cxtDragged=!0;var R=e.findNearestElement(s[0],s[1],!0,!0);e.touchData.cxtOver&&R===e.touchData.cxtOver||(e.touchData.cxtOver&&e.touchData.cxtOver.emit({originalEvent:t,type:"cxtdragout",position:{x:s[0],y:s[1]}}),e.touchData.cxtOver=R,R&&R.emit({originalEvent:t,type:"cxtdragover",position:{x:s[0],y:s[1]}}))}else if(n&&t.touches[2]&&o.boxSelectionEnabled())t.preventDefault(),e.data.bgActivePosistion=void 0,this.lastThreeTouch=+new Date,e.touchData.selecting||o.emit({originalEvent:t,type:"boxstart",position:{x:s[0],y:s[1]}}),e.touchData.selecting=!0,e.touchData.didSelect=!0,i[4]=1,i&&0!==i.length&&void 0!==i[0]?(i[2]=(s[0]+s[2]+s[4])/3,i[3]=(s[1]+s[3]+s[5])/3):(i[0]=(s[0]+s[2]+s[4])/3,i[1]=(s[1]+s[3]+s[5])/3,i[2]=(s[0]+s[2]+s[4])/3+1,i[3]=(s[1]+s[3]+s[5])/3+1),e.redrawHint("select",!0),e.redraw();else if(n&&t.touches[1]&&!e.touchData.didSelect&&o.zoomingEnabled()&&o.panningEnabled()&&o.userZoomingEnabled()&&o.userPanningEnabled()){if(t.preventDefault(),e.data.bgActivePosistion=void 0,e.redrawHint("select",!0),ee=e.dragData.touchDragEles){e.redrawHint("drag",!0);for(var V=0;V0&&!e.hoverData.draggingEles&&!e.swipePanning&&null!=e.data.bgActivePosistion&&(e.data.bgActivePosistion=void 0,e.redrawHint("select",!0),e.redraw())}},!1),e.registerBinding(t,"touchcancel",F=function(t){var n=e.touchData.start;e.touchData.capture=!1,n&&n.unactivate()}),e.registerBinding(t,"touchend",j=function(t){var i=e.touchData.start;if(e.touchData.capture){0===t.touches.length&&(e.touchData.capture=!1),t.preventDefault();var a=e.selection;e.swipePanning=!1,e.hoverData.draggingEles=!1;var o,s=e.cy,l=s.zoom(),u=e.touchData.now,c=e.touchData.earlier;if(t.touches[0]){var h=e.projectIntoViewport(t.touches[0].clientX,t.touches[0].clientY);u[0]=h[0],u[1]=h[1]}if(t.touches[1]){h=e.projectIntoViewport(t.touches[1].clientX,t.touches[1].clientY);u[2]=h[0],u[3]=h[1]}if(t.touches[2]){h=e.projectIntoViewport(t.touches[2].clientX,t.touches[2].clientY);u[4]=h[0],u[5]=h[1]}if(i&&i.unactivate(),e.touchData.cxt){if(o={originalEvent:t,type:"cxttapend",position:{x:u[0],y:u[1]}},i?i.emit(o):s.emit(o),!e.touchData.cxtDragged){var p={originalEvent:t,type:"cxttap",position:{x:u[0],y:u[1]}};i?i.emit(p):s.emit(p)}return e.touchData.start&&(e.touchData.start._private.grabbed=!1),e.touchData.cxt=!1,e.touchData.start=null,void e.redraw()}if(!t.touches[2]&&s.boxSelectionEnabled()&&e.touchData.selecting){e.touchData.selecting=!1;var f=s.collection(e.getAllInBox(a[0],a[1],a[2],a[3]));a[0]=void 0,a[1]=void 0,a[2]=void 0,a[3]=void 0,a[4]=0,e.redrawHint("select",!0),s.emit({type:"boxend",originalEvent:t,position:{x:u[0],y:u[1]}});f.emit("box").stdFilter((function(e){return e.selectable()&&!e.selected()})).select().emit("boxselect"),f.nonempty()&&e.redrawHint("eles",!0),e.redraw()}if(null!=i&&i.unactivate(),t.touches[2])e.data.bgActivePosistion=void 0,e.redrawHint("select",!0);else if(t.touches[1]);else if(t.touches[0]);else if(!t.touches[0]){e.data.bgActivePosistion=void 0,e.redrawHint("select",!0);var g=e.dragData.touchDragEles;if(null!=i){var v=i._private.grabbed;d(g),e.redrawHint("drag",!0),e.redrawHint("eles",!0),v&&(i.emit("freeon"),g.emit("free"),e.dragData.didDrag&&(i.emit("dragfreeon"),g.emit("dragfree"))),r(i,["touchend","tapend","vmouseup","tapdragout"],t,{x:u[0],y:u[1]}),i.unactivate(),e.touchData.start=null}else{var y=e.findNearestElement(u[0],u[1],!0,!0);r(y,["touchend","tapend","vmouseup","tapdragout"],t,{x:u[0],y:u[1]})}var m=e.touchData.startPosition[0]-u[0],b=m*m,x=e.touchData.startPosition[1]-u[1],w=(b+x*x)*l*l;e.touchData.singleTouchMoved||(i||s.$(":selected").unselect(["tapunselect"]),r(i,["tap","vclick"],t,{x:u[0],y:u[1]}),q=!1,t.timeStamp-X<=s.multiClickDebounceTime()?(Y&&clearTimeout(Y),q=!0,X=null,r(i,["dbltap","vdblclick"],t,{x:u[0],y:u[1]})):(Y=setTimeout((function(){q||r(i,["onetap","voneclick"],t,{x:u[0],y:u[1]})}),s.multiClickDebounceTime()),X=t.timeStamp)),null!=i&&!e.dragData.didDrag&&i._private.selectable&&w2){for(var p=[c[0],c[1]],f=Math.pow(p[0]-e,2)+Math.pow(p[1]-t,2),g=1;g0)return g[0]}return null},p=Object.keys(d),f=0;f0?u:Rt(i,a,e,t,n,r,o,s)},checkPoint:function(e,t,n,r,i,a,o,s){var l=2*(s="auto"===s?nn(r,i):s);if(Xt(e,t,this.points,a,o,r,i-l,[0,-1],n))return!0;if(Xt(e,t,this.points,a,o,r-l,i,[0,-1],n))return!0;var u=r/2+2*n,c=i/2+2*n;return!!Yt(e,t,[a-u,o-c,a-u,o,a+u,o,a+u,o-c])||(!!Kt(e,t,l,l,a+r/2-s,o+i/2-s,n)||!!Kt(e,t,l,l,a-r/2+s,o+i/2-s,n))}}},gu.registerNodeShapes=function(){var e=this.nodeShapes={},t=this;this.generateEllipse(),this.generatePolygon("triangle",Jt(3,0)),this.generateRoundPolygon("round-triangle",Jt(3,0)),this.generatePolygon("rectangle",Jt(4,0)),e.square=e.rectangle,this.generateRoundRectangle(),this.generateCutRectangle(),this.generateBarrel(),this.generateBottomRoundrectangle();var n=[0,1,1,0,0,-1,-1,0];this.generatePolygon("diamond",n),this.generateRoundPolygon("round-diamond",n),this.generatePolygon("pentagon",Jt(5,0)),this.generateRoundPolygon("round-pentagon",Jt(5,0)),this.generatePolygon("hexagon",Jt(6,0)),this.generateRoundPolygon("round-hexagon",Jt(6,0)),this.generatePolygon("heptagon",Jt(7,0)),this.generateRoundPolygon("round-heptagon",Jt(7,0)),this.generatePolygon("octagon",Jt(8,0)),this.generateRoundPolygon("round-octagon",Jt(8,0));var r=new Array(20),i=tn(5,0),a=tn(5,Math.PI/5),o=.5*(3-Math.sqrt(5));o*=1.57;for(var s=0;s=e.deqFastCost*g)break}else if(i){if(p>=e.deqCost*l||p>=e.deqAvgCost*s)break}else if(f>=e.deqNoDrawCost*(1e3/60))break;var v=e.deq(t,d,c);if(!(v.length>0))break;for(var y=0;y0&&(e.onDeqd(t,u),!i&&e.shouldRedraw(t,u,d,c)&&r())}),i(t))}}},wu=function(){function e(n){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:Le;t(this,e),this.idsByKey=new $e,this.keyForId=new $e,this.cachesByLvl=new $e,this.lvls=[],this.getKey=n,this.doesEleInvalidateKey=r}return r(e,[{key:"getIdsFor",value:function(e){null==e&&Ve("Can not get id list for null key");var t=this.idsByKey,n=this.idsByKey.get(e);return n||(n=new Je,t.set(e,n)),n}},{key:"addIdForKey",value:function(e,t){null!=e&&this.getIdsFor(e).add(t)}},{key:"deleteIdForKey",value:function(e,t){null!=e&&this.getIdsFor(e).delete(t)}},{key:"getNumberOfIdsForKey",value:function(e){return null==e?0:this.getIdsFor(e).size}},{key:"updateKeyMappingFor",value:function(e){var t=e.id(),n=this.keyForId.get(t),r=this.getKey(e);this.deleteIdForKey(n,t),this.addIdForKey(r,t),this.keyForId.set(t,r)}},{key:"deleteKeyMappingFor",value:function(e){var t=e.id(),n=this.keyForId.get(t);this.deleteIdForKey(n,t),this.keyForId.delete(t)}},{key:"keyHasChangedFor",value:function(e){var t=e.id();return this.keyForId.get(t)!==this.getKey(e)}},{key:"isInvalid",value:function(e){return this.keyHasChangedFor(e)||this.doesEleInvalidateKey(e)}},{key:"getCachesAt",value:function(e){var t=this.cachesByLvl,n=this.lvls,r=t.get(e);return r||(r=new $e,t.set(e,r),n.push(e)),r}},{key:"getCache",value:function(e,t){return this.getCachesAt(t).get(e)}},{key:"get",value:function(e,t){var n=this.getKey(e),r=this.getCache(n,t);return null!=r&&this.updateKeyMappingFor(e),r}},{key:"getForCachedKey",value:function(e,t){var n=this.keyForId.get(e.id());return this.getCache(n,t)}},{key:"hasCache",value:function(e,t){return this.getCachesAt(t).has(e)}},{key:"has",value:function(e,t){var n=this.getKey(e);return this.hasCache(n,t)}},{key:"setCache",value:function(e,t,n){n.key=e,this.getCachesAt(t).set(e,n)}},{key:"set",value:function(e,t,n){var r=this.getKey(e);this.setCache(r,t,n),this.updateKeyMappingFor(e)}},{key:"deleteCache",value:function(e,t){this.getCachesAt(t).delete(e)}},{key:"delete",value:function(e,t){var n=this.getKey(e);this.deleteCache(n,t)}},{key:"invalidateKey",value:function(e){var t=this;this.lvls.forEach((function(n){return t.deleteCache(e,n)}))}},{key:"invalidate",value:function(e){var t=e.id(),n=this.keyForId.get(t);this.deleteKeyMappingFor(e);var r=this.doesEleInvalidateKey(e);return r&&this.invalidateKey(n),r||0===this.getNumberOfIdsForKey(n)}}]),e}(),Eu={dequeue:"dequeue",downscale:"downscale",highQuality:"highQuality"},ku=He({getKey:null,doesEleInvalidateKey:Le,drawElement:null,getBoundingBox:null,getRotationPoint:null,getRotationOffset:null,isVisible:Ae,allowEdgeTxrCaching:!0,allowParentTxrCaching:!0}),Cu=function(e,t){this.renderer=e,this.onDequeues=[];var n=ku(t);L(this,n),this.lookup=new wu(n.getKey,n.doesEleInvalidateKey),this.setupDequeueing()},Su=Cu.prototype;Su.reasons=Eu,Su.getTextureQueue=function(e){return this.eleImgCaches=this.eleImgCaches||{},this.eleImgCaches[e]=this.eleImgCaches[e]||[]},Su.getRetiredTextureQueue=function(e){var t=this.eleImgCaches.retired=this.eleImgCaches.retired||{};return t[e]=t[e]||[]},Su.getElementQueue=function(){return this.eleCacheQueue=this.eleCacheQueue||new rt((function(e,t){return t.reqs-e.reqs}))},Su.getElementKeyToQueue=function(){return this.eleKeyToCacheQueue=this.eleKeyToCacheQueue||{}},Su.getElement=function(e,t,n,r,i){var a=this,o=this.renderer,s=o.cy.zoom(),l=this.lookup;if(!t||0===t.w||0===t.h||isNaN(t.w)||isNaN(t.h)||!e.visible()||e.removed())return null;if(!a.allowEdgeTxrCaching&&e.isEdge()||!a.allowParentTxrCaching&&e.isParent())return null;if(null==r&&(r=Math.ceil(wt(s*n))),r<-4)r=-4;else if(s>=7.99||r>3)return null;var u=Math.pow(2,r),c=t.h*u,d=t.w*u,h=o.eleTextBiggerThanMin(e,u);if(!this.isVisible(e,h))return null;var p,f=l.get(e,r);if(f&&f.invalidated&&(f.invalidated=!1,f.texture.invalidatedWidth-=f.width),f)return f;if(p=c<=25?25:c<=50?50:50*Math.ceil(c/50),c>1024||d>1024)return null;var g=a.getTextureQueue(p),v=g[g.length-2],y=function(){return a.recycleTexture(p,d)||a.addTexture(p,d)};v||(v=g[g.length-1]),v||(v=y()),v.width-v.usedWidthr;D--)S=a.getElement(e,t,n,D,Eu.downscale);P()}else{var T;if(!x&&!w&&!E)for(var _=r-1;_>=-4;_--){var M=l.get(e,_);if(M){T=M;break}}if(b(T))return a.queueElement(e,r),T;v.context.translate(v.usedWidth,0),v.context.scale(u,u),this.drawElement(v.context,e,t,h,!1),v.context.scale(1/u,1/u),v.context.translate(-v.usedWidth,0)}return f={x:v.usedWidth,texture:v,level:r,scale:u,width:d,height:c,scaledLabelShown:h},v.usedWidth+=Math.ceil(d+8),v.eleCaches.push(f),l.set(e,r,f),a.checkTextureFullness(v),f},Su.invalidateElements=function(e){for(var t=0;t=.2*e.width&&this.retireTexture(e)},Su.checkTextureFullness=function(e){var t=this.getTextureQueue(e.height);e.usedWidth/e.width>.8&&e.fullnessChecks>=10?Ke(t,e):e.fullnessChecks++},Su.retireTexture=function(e){var t=e.height,n=this.getTextureQueue(t),r=this.lookup;Ke(n,e),e.retired=!0;for(var i=e.eleCaches,a=0;a=t)return a.retired=!1,a.usedWidth=0,a.invalidatedWidth=0,a.fullnessChecks=0,Ge(a.eleCaches),a.context.setTransform(1,0,0,1,0,0),a.context.clearRect(0,0,a.width,a.height),Ke(r,a),n.push(a),a}},Su.queueElement=function(e,t){var n=this.getElementQueue(),r=this.getElementKeyToQueue(),i=this.getKey(e),a=r[i];if(a)a.level=Math.max(a.level,t),a.eles.merge(e),a.reqs++,n.updateItem(a);else{var o={eles:e.spawn().merge(e),level:t,reqs:1,key:i};n.push(o),r[i]=o}},Su.dequeue=function(e){for(var t=this.getElementQueue(),n=this.getElementKeyToQueue(),r=[],i=this.lookup,a=0;a<1&&t.size()>0;a++){var o=t.pop(),s=o.key,l=o.eles[0],u=i.hasCache(l,o.level);if(n[s]=null,!u){r.push(o);var c=this.getBoundingBox(l);this.getElement(l,c,e,o.level,Eu.dequeue)}}return r},Su.removeFromQueue=function(e){var t=this.getElementQueue(),n=this.getElementKeyToQueue(),r=this.getKey(e),i=n[r];null!=i&&(1===i.eles.length?(i.reqs=Ie,t.updateItem(i),t.pop(),n[r]=null):i.eles.unmerge(e))},Su.onDequeue=function(e){this.onDequeues.push(e)},Su.offDequeue=function(e){Ke(this.onDequeues,e)},Su.setupDequeueing=xu({deqRedrawThreshold:100,deqCost:.15,deqAvgCost:.1,deqNoDrawCost:.9,deqFastCost:.9,deq:function(e,t,n){return e.dequeue(t,n)},onDeqd:function(e,t){for(var n=0;n=3.99||n>2)return null;r.validateLayersElesOrdering(n,e);var o,s,l=r.layersByLevel,u=Math.pow(2,n),c=l[n]=l[n]||[];if(r.levelIsComplete(n,e))return c;!function(){var t=function(t){if(r.validateLayersElesOrdering(t,e),r.levelIsComplete(t,e))return s=l[t],!0},i=function(e){if(!s)for(var r=n+e;-4<=r&&r<=2&&!t(r);r+=e);};i(1),i(-1);for(var a=c.length-1;a>=0;a--){var o=c[a];o.invalid&&Ke(c,o)}}();var d=function(t){var i=(t=t||{}).after;!function(){if(!o){o=_t();for(var t=0;t32767||s>32767)return null;if(a*s>16e6)return null;var l=r.makeLayer(o,n);if(null!=i){var d=c.indexOf(i)+1;c.splice(d,0,l)}else(void 0===t.insert||t.insert)&&c.unshift(l);return l};if(r.skipping&&!a)return null;for(var h=null,p=e.length/1,f=!a,g=0;g=p||!Ot(h.bb,v.boundingBox()))&&!(h=d({insert:!0,after:h})))return null;s||f?r.queueLayer(h,v):r.drawEleInLayer(h,v,n,t),h.eles.push(v),m[n]=h}}return s||(f?null:c)},Du.getEleLevelForLayerLevel=function(e,t){return e},Du.drawEleInLayer=function(e,t,n,r){var i=this.renderer,a=e.context,o=t.boundingBox();0!==o.w&&0!==o.h&&t.visible()&&(n=this.getEleLevelForLayerLevel(n,r),i.setImgSmoothing(a,!1),i.drawCachedElement(a,t,null,null,n,!0),i.setImgSmoothing(a,!0))},Du.levelIsComplete=function(e,t){var n=this.layersByLevel[e];if(!n||0===n.length)return!1;for(var r=0,i=0;i0)return!1;if(a.invalid)return!1;r+=a.eles.length}return r===t.length},Du.validateLayersElesOrdering=function(e,t){var n=this.layersByLevel[e];if(n)for(var r=0;r0){e=!0;break}}return e},Du.invalidateElements=function(e){var t=this;0!==e.length&&(t.lastInvalidationTime=we(),0!==e.length&&t.haveLayers()&&t.updateElementsInLayers(e,(function(e,n,r){t.invalidateLayer(e)})))},Du.invalidateLayer=function(e){if(this.lastInvalidationTime=we(),!e.invalid){var t=e.level,n=e.eles,r=this.layersByLevel[t];Ke(r,e),e.elesQueue=[],e.invalid=!0,e.replacement&&(e.replacement.invalid=!0);for(var i=0;i3&&void 0!==arguments[3])||arguments[3],i=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],a=!(arguments.length>5&&void 0!==arguments[5])||arguments[5],o=this,s=t._private.rscratch;if((!a||t.visible())&&!s.badLine&&null!=s.allpts&&!isNaN(s.allpts[0])){var l;n&&(l=n,e.translate(-l.x1,-l.y1));var u=a?t.pstyle("opacity").value:1,c=a?t.pstyle("line-opacity").value:1,d=t.pstyle("curve-style").value,h=t.pstyle("line-style").value,p=t.pstyle("width").pfValue,f=t.pstyle("line-cap").value,g=t.pstyle("line-outline-width").value,v=t.pstyle("line-outline-color").value,y=u*c,m=u*c,b=function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:y;"straight-triangle"===d?(o.eleStrokeStyle(e,t,n),o.drawEdgeTrianglePath(t,e,s.allpts)):(e.lineWidth=p,e.lineCap=f,o.eleStrokeStyle(e,t,n),o.drawEdgePath(t,e,s.allpts,h),e.lineCap="butt")},x=function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:y;e.lineWidth=p+g,e.lineCap=f,g>0?(o.colorStrokeStyle(e,v[0],v[1],v[2],n),"straight-triangle"===d?o.drawEdgeTrianglePath(t,e,s.allpts):(o.drawEdgePath(t,e,s.allpts,h),e.lineCap="butt")):e.lineCap="butt"},w=function(){i&&o.drawEdgeOverlay(e,t)},E=function(){i&&o.drawEdgeUnderlay(e,t)},k=function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:m;o.drawArrowheads(e,t,n)},C=function(){o.drawElementText(e,t,null,r)};e.lineJoin="round";var S="yes"===t.pstyle("ghost").value;if(S){var P=t.pstyle("ghost-offset-x").pfValue,D=t.pstyle("ghost-offset-y").pfValue,T=t.pstyle("ghost-opacity").value,_=y*T;e.translate(P,D),b(_),k(_),e.translate(-P,-D)}else x();E(),b(),k(),w(),C(),n&&e.translate(l.x1,l.y1)}}},Wu=function(e){if(!["overlay","underlay"].includes(e))throw new Error("Invalid state");return function(t,n){if(n.visible()){var r=n.pstyle("".concat(e,"-opacity")).value;if(0!==r){var i=this,a=i.usePaths(),o=n._private.rscratch,s=2*n.pstyle("".concat(e,"-padding")).pfValue,l=n.pstyle("".concat(e,"-color")).value;t.lineWidth=s,"self"!==o.edgeType||a?t.lineCap="round":t.lineCap="butt",i.colorStrokeStyle(t,l[0],l[1],l[2],r),i.drawEdgePath(n,t,o.allpts,"solid")}}}};Xu.drawEdgeOverlay=Wu("overlay"),Xu.drawEdgeUnderlay=Wu("underlay"),Xu.drawEdgePath=function(e,t,n,r){var i,a=e._private.rscratch,o=t,s=!1,u=this.usePaths(),c=e.pstyle("line-dash-pattern").pfValue,d=e.pstyle("line-dash-offset").pfValue;if(u){var h=n.join("$");a.pathCacheKey&&a.pathCacheKey===h?(i=t=a.pathCache,s=!0):(i=t=new Path2D,a.pathCacheKey=h,a.pathCache=i)}if(o.setLineDash)switch(r){case"dotted":o.setLineDash([1,1]);break;case"dashed":o.setLineDash(c),o.lineDashOffset=d;break;case"solid":o.setLineDash([])}if(!s&&!a.badLine)switch(t.beginPath&&t.beginPath(),t.moveTo(n[0],n[1]),a.edgeType){case"bezier":case"self":case"compound":case"multibezier":for(var p=2;p+35&&void 0!==arguments[5]?arguments[5]:5,o=arguments.length>6?arguments[6]:void 0;e.beginPath(),e.moveTo(t+a,n),e.lineTo(t+r-a,n),e.quadraticCurveTo(t+r,n,t+r,n+a),e.lineTo(t+r,n+i-a),e.quadraticCurveTo(t+r,n+i,t+r-a,n+i),e.lineTo(t+a,n+i),e.quadraticCurveTo(t,n+i,t,n+i-a),e.lineTo(t,n+a),e.quadraticCurveTo(t,n,t+a,n),e.closePath(),o?e.stroke():e.fill()}Ku.eleTextBiggerThanMin=function(e,t){if(!t){var n=e.cy().zoom(),r=this.getPixelRatio(),i=Math.ceil(wt(n*r));t=Math.pow(2,i)}return!(e.pstyle("font-size").pfValue*t5&&void 0!==arguments[5])||arguments[5],o=this;if(null==r){if(a&&!o.eleTextBiggerThanMin(t))return}else if(!1===r)return;if(t.isNode()){var s=t.pstyle("label");if(!s||!s.value)return;var l=o.getLabelJustification(t);e.textAlign=l,e.textBaseline="bottom"}else{var u=t.element()._private.rscratch.badLine,c=t.pstyle("label"),d=t.pstyle("source-label"),h=t.pstyle("target-label");if(u||(!c||!c.value)&&(!d||!d.value)&&(!h||!h.value))return;e.textAlign="center",e.textBaseline="bottom"}var p,f=!n;n&&(p=n,e.translate(-p.x1,-p.y1)),null==i?(o.drawText(e,t,null,f,a),t.isEdge()&&(o.drawText(e,t,"source",f,a),o.drawText(e,t,"target",f,a))):o.drawText(e,t,i,f,a),n&&e.translate(p.x1,p.y1)},Ku.getFontCache=function(e){var t;this.fontCaches=this.fontCaches||[];for(var n=0;n2&&void 0!==arguments[2])||arguments[2],r=t.pstyle("font-style").strValue,i=t.pstyle("font-size").pfValue+"px",a=t.pstyle("font-family").strValue,o=t.pstyle("font-weight").strValue,s=n?t.effectiveOpacity()*t.pstyle("text-opacity").value:1,l=t.pstyle("text-outline-opacity").value*s,u=t.pstyle("color").value,c=t.pstyle("text-outline-color").value;e.font=r+" "+o+" "+i+" "+a,e.lineJoin="round",this.colorFillStyle(e,u[0],u[1],u[2],s),this.colorStrokeStyle(e,c[0],c[1],c[2],l)},Ku.getTextAngle=function(e,t){var n=e._private.rscratch,r=t?t+"-":"",i=e.pstyle(r+"text-rotation"),a=Ue(n,"labelAngle",t);return"autorotate"===i.strValue?e.isEdge()?a:0:"none"===i.strValue?0:i.pfValue},Ku.drawText=function(e,t,n){var r=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],i=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],a=t._private,o=a.rscratch,s=i?t.effectiveOpacity():1;if(!i||0!==s&&0!==t.pstyle("text-opacity").value){"main"===n&&(n=null);var l,u,c=Ue(o,"labelX",n),d=Ue(o,"labelY",n),h=this.getLabelText(t,n);if(null!=h&&""!==h&&!isNaN(c)&&!isNaN(d)){this.setupTextStyle(e,t,i);var p,f=n?n+"-":"",g=Ue(o,"labelWidth",n),v=Ue(o,"labelHeight",n),y=t.pstyle(f+"text-margin-x").pfValue,m=t.pstyle(f+"text-margin-y").pfValue,b=t.isEdge(),x=t.pstyle("text-halign").value,w=t.pstyle("text-valign").value;switch(b&&(x="center",w="center"),c+=y,d+=m,0!==(p=r?this.getTextAngle(t,n):0)&&(l=c,u=d,e.translate(l,u),e.rotate(p),c=0,d=0),w){case"top":break;case"center":d+=v/2;break;case"bottom":d+=v}var E=t.pstyle("text-background-opacity").value,k=t.pstyle("text-border-opacity").value,C=t.pstyle("text-border-width").pfValue,S=t.pstyle("text-background-padding").pfValue,P=t.pstyle("text-background-shape").strValue,D=0===P.indexOf("round"),T=2;if(E>0||C>0&&k>0){var _=c-S;switch(x){case"left":_-=g;break;case"center":_-=g/2}var M=d-v-S,B=g+2*S,N=v+2*S;if(E>0){var z=e.fillStyle,I=t.pstyle("text-background-color").value;e.fillStyle="rgba("+I[0]+","+I[1]+","+I[2]+","+E*s+")",D?Gu(e,_,M,B,N,T):e.fillRect(_,M,B,N),e.fillStyle=z}if(C>0&&k>0){var A=e.strokeStyle,L=e.lineWidth,O=t.pstyle("text-border-color").value,R=t.pstyle("text-border-style").value;if(e.strokeStyle="rgba("+O[0]+","+O[1]+","+O[2]+","+k*s+")",e.lineWidth=C,e.setLineDash)switch(R){case"dotted":e.setLineDash([1,1]);break;case"dashed":e.setLineDash([4,2]);break;case"double":e.lineWidth=C/4,e.setLineDash([]);break;case"solid":e.setLineDash([])}if(D?Gu(e,_,M,B,N,T,"stroke"):e.strokeRect(_,M,B,N),"double"===R){var V=C/2;D?Gu(e,_+V,M+V,B-2*V,N-2*V,T,"stroke"):e.strokeRect(_+V,M+V,B-2*V,N-2*V)}e.setLineDash&&e.setLineDash([]),e.lineWidth=L,e.strokeStyle=A}}var F=2*t.pstyle("text-outline-width").pfValue;if(F>0&&(e.lineWidth=F),"wrap"===t.pstyle("text-wrap").value){var j=Ue(o,"labelWrapCachedLines",n),q=Ue(o,"labelLineHeight",n),Y=g/2,X=this.getLabelJustification(t);switch("auto"===X||("left"===x?"left"===X?c+=-g:"center"===X&&(c+=-Y):"center"===x?"left"===X?c+=-Y:"right"===X&&(c+=Y):"right"===x&&("center"===X?c+=Y:"right"===X&&(c+=g))),w){case"top":d-=(j.length-1)*q;break;case"center":case"bottom":d-=(j.length-1)*q}for(var W=0;W0&&e.strokeText(j[W],c,d),e.fillText(j[W],c,d),d+=q}else F>0&&e.strokeText(h,c,d),e.fillText(h,c,d);0!==p&&(e.rotate(-p),e.translate(-l,-u))}}};var Uu={drawNode:function(e,t,n){var r,i,a=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],o=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],s=!(arguments.length>5&&void 0!==arguments[5])||arguments[5],l=this,u=t._private,c=u.rscratch,d=t.position();if(x(d.x)&&x(d.y)&&(!s||t.visible())){var h,p,f=s?t.effectiveOpacity():1,g=l.usePaths(),v=!1,y=t.padding();r=t.width()+2*y,i=t.height()+2*y,n&&(p=n,e.translate(-p.x1,-p.y1));for(var m=t.pstyle("background-image"),b=m.value,w=new Array(b.length),E=new Array(b.length),k=0,C=0;C0&&void 0!==arguments[0]?arguments[0]:M;l.eleFillStyle(e,t,n)},H=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:R;l.colorStrokeStyle(e,B[0],B[1],B[2],t)},K=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:q;l.colorStrokeStyle(e,F[0],F[1],F[2],t)},G=function(e,t,n,r){var i,a=l.nodePathCache=l.nodePathCache||[],o=_e("polygon"===n?n+","+r.join(","):n,""+t,""+e,""+X),s=a[o],u=!1;return null!=s?(i=s,u=!0,c.pathCache=i):(i=new Path2D,a[o]=c.pathCache=i),{path:i,cacheHit:u}},U=t.pstyle("shape").strValue,Z=t.pstyle("shape-polygon-points").pfValue;if(g){e.translate(d.x,d.y);var $=G(r,i,U,Z);h=$.path,v=$.cacheHit}var Q=function(){if(!v){var n=d;g&&(n={x:0,y:0}),l.nodeShapes[l.getNodeShape(t)].draw(h||e,n.x,n.y,r,i,X,c)}g?e.fill(h):e.fill()},J=function(){for(var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:f,r=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],i=u.backgrounding,a=0,o=0;o0&&void 0!==arguments[0]&&arguments[0],a=arguments.length>1&&void 0!==arguments[1]?arguments[1]:f;l.hasPie(t)&&(l.drawPie(e,t,a),n&&(g||l.nodeShapes[l.getNodeShape(t)].draw(e,d.x,d.y,r,i,X,c)))},te=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:f,n=(T>0?T:-T)*t,r=T>0?0:255;0!==T&&(l.colorFillStyle(e,r,r,r,n),g?e.fill(h):e.fill())},ne=function(){if(_>0){if(e.lineWidth=_,e.lineCap=I,e.lineJoin=z,e.setLineDash)switch(N){case"dotted":e.setLineDash([1,1]);break;case"dashed":e.setLineDash(L),e.lineDashOffset=O;break;case"solid":case"double":e.setLineDash([])}if("center"!==A){if(e.save(),e.lineWidth*=2,"inside"===A)g?e.clip(h):e.clip();else{var t=new Path2D;t.rect(-r/2-_,-i/2-_,r+2*_,i+2*_),t.addPath(h),e.clip(t,"evenodd")}g?e.stroke(h):e.stroke(),e.restore()}else g?e.stroke(h):e.stroke();if("double"===N){e.lineWidth=_/3;var n=e.globalCompositeOperation;e.globalCompositeOperation="destination-out",g?e.stroke(h):e.stroke(),e.globalCompositeOperation=n}e.setLineDash&&e.setLineDash([])}},re=function(){if(V>0){if(e.lineWidth=V,e.lineCap="butt",e.setLineDash)switch(j){case"dotted":e.setLineDash([1,1]);break;case"dashed":e.setLineDash([4,2]);break;case"solid":case"double":e.setLineDash([])}var n=d;g&&(n={x:0,y:0});var a=l.getNodeShape(t),o=_;"inside"===A&&(o=0),"outside"===A&&(o*=2);var s,u=(r+o+(V+Y))/r,c=(i+o+(V+Y))/i,h=r*u,p=i*c,f=l.nodeShapes[a].points;if(g)s=G(h,p,a,f).path;if("ellipse"===a)l.drawEllipsePath(s||e,n.x,n.y,h,p);else if(["round-diamond","round-heptagon","round-hexagon","round-octagon","round-pentagon","round-polygon","round-triangle","round-tag"].includes(a)){var v=0,y=0,m=0;"round-diamond"===a?v=1.4*(o+Y+V):"round-heptagon"===a?(v=1.075*(o+Y+V),m=-(o/2+Y+V)/35):"round-hexagon"===a?v=1.12*(o+Y+V):"round-pentagon"===a?(v=1.13*(o+Y+V),m=-(o/2+Y+V)/15):"round-tag"===a?(v=1.12*(o+Y+V),y=.07*(o/2+V+Y)):"round-triangle"===a&&(v=(o+Y+V)*(Math.PI/2),m=-(o+Y/2+V)/Math.PI),0!==v&&(h=r*(u=(r+v)/r),["round-hexagon","round-tag"].includes(a)||(p=i*(c=(i+v)/i)));for(var b=h/2,x=p/2,w=(X="auto"===X?rn(h,p):X)+(o+V+Y)/2,E=new Array(f.length/2),k=new Array(f.length/2),C=0;C0){if(r=r||n.position(),null==i||null==a){var d=n.padding();i=n.width()+2*d,a=n.height()+2*d}this.colorFillStyle(t,l[0],l[1],l[2],s),this.nodeShapes[u].draw(t,r.x,r.y,i+2*o,a+2*o,c),t.fill()}}}};Uu.drawNodeOverlay=Zu("overlay"),Uu.drawNodeUnderlay=Zu("underlay"),Uu.hasPie=function(e){return(e=e[0])._private.hasPie},Uu.drawPie=function(e,t,n,r){t=t[0],r=r||t.position();var i=t.cy().style(),a=t.pstyle("pie-size"),o=r.x,s=r.y,l=t.width(),u=t.height(),c=Math.min(l,u)/2,d=0;this.usePaths()&&(o=0,s=0),"%"===a.units?c*=a.pfValue:void 0!==a.pfValue&&(c=a.pfValue/2);for(var h=1;h<=i.pieBackgroundN;h++){var p=t.pstyle("pie-"+h+"-background-size").value,f=t.pstyle("pie-"+h+"-background-color").value,g=t.pstyle("pie-"+h+"-background-opacity").value*n,v=p/100;v+d>1&&(v=1-d);var y=1.5*Math.PI+2*Math.PI*d,m=y+2*Math.PI*v;0===p||d>=1||d+v>1||(e.beginPath(),e.moveTo(o,s),e.arc(o,s,c,y,m),e.closePath(),this.colorFillStyle(e,f[0],f[1],f[2],g),e.fill(),d+=v)}};var $u={};$u.getPixelRatio=function(){var e=this.data.contexts[0];if(null!=this.forcedPixelRatio)return this.forcedPixelRatio;var t=this.cy.window(),n=e.backingStorePixelRatio||e.webkitBackingStorePixelRatio||e.mozBackingStorePixelRatio||e.msBackingStorePixelRatio||e.oBackingStorePixelRatio||e.backingStorePixelRatio||1;return(t.devicePixelRatio||1)/n},$u.paintCache=function(e){for(var t,n=this.paintCaches=this.paintCaches||[],r=!0,i=0;io.minMbLowQualFrames&&(o.motionBlurPxRatio=o.mbPxRBlurry)),o.clearingMotionBlur&&(o.motionBlurPxRatio=1),o.textureDrawLastFrame&&!d&&(c[o.NODE]=!0,c[o.SELECT_BOX]=!0);var m=l.style(),b=l.zoom(),x=void 0!==i?i:b,w=l.pan(),E={x:w.x,y:w.y},k={zoom:b,pan:{x:w.x,y:w.y}},C=o.prevViewport;void 0===C||k.zoom!==C.zoom||k.pan.x!==C.pan.x||k.pan.y!==C.pan.y||g&&!f||(o.motionBlurPxRatio=1),a&&(E=a),x*=s,E.x*=s,E.y*=s;var S=o.getCachedZSortedEles();function P(e,t,n,r,i){var a=e.globalCompositeOperation;e.globalCompositeOperation="destination-out",o.colorFillStyle(e,255,255,255,o.motionBlurTransparency),e.fillRect(t,n,r,i),e.globalCompositeOperation=a}function D(e,r){var s,l,c,d;o.clearingMotionBlur||e!==u.bufferContexts[o.MOTIONBLUR_BUFFER_NODE]&&e!==u.bufferContexts[o.MOTIONBLUR_BUFFER_DRAG]?(s=E,l=x,c=o.canvasWidth,d=o.canvasHeight):(s={x:w.x*p,y:w.y*p},l=b*p,c=o.canvasWidth*p,d=o.canvasHeight*p),e.setTransform(1,0,0,1,0,0),"motionBlur"===r?P(e,0,0,c,d):t||void 0!==r&&!r||e.clearRect(0,0,c,d),n||(e.translate(s.x,s.y),e.scale(l,l)),a&&e.translate(a.x,a.y),i&&e.scale(i,i)}if(d||(o.textureDrawLastFrame=!1),d){if(o.textureDrawLastFrame=!0,!o.textureCache){o.textureCache={},o.textureCache.bb=l.mutableElements().boundingBox(),o.textureCache.texture=o.data.bufferCanvases[o.TEXTURE_BUFFER];var T=o.data.bufferContexts[o.TEXTURE_BUFFER];T.setTransform(1,0,0,1,0,0),T.clearRect(0,0,o.canvasWidth*o.textureMult,o.canvasHeight*o.textureMult),o.render({forcedContext:T,drawOnlyNodeLayer:!0,forcedPxRatio:s*o.textureMult}),(k=o.textureCache.viewport={zoom:l.zoom(),pan:l.pan(),width:o.canvasWidth,height:o.canvasHeight}).mpan={x:(0-k.pan.x)/k.zoom,y:(0-k.pan.y)/k.zoom}}c[o.DRAG]=!1,c[o.NODE]=!1;var _=u.contexts[o.NODE],M=o.textureCache.texture;k=o.textureCache.viewport;_.setTransform(1,0,0,1,0,0),h?P(_,0,0,k.width,k.height):_.clearRect(0,0,k.width,k.height);var B=m.core("outside-texture-bg-color").value,N=m.core("outside-texture-bg-opacity").value;o.colorFillStyle(_,B[0],B[1],B[2],N),_.fillRect(0,0,k.width,k.height);b=l.zoom();D(_,!1),_.clearRect(k.mpan.x,k.mpan.y,k.width/k.zoom/s,k.height/k.zoom/s),_.drawImage(M,k.mpan.x,k.mpan.y,k.width/k.zoom/s,k.height/k.zoom/s)}else o.textureOnViewport&&!t&&(o.textureCache=null);var z=l.extent(),I=o.pinching||o.hoverData.dragging||o.swipePanning||o.data.wheelZooming||o.hoverData.draggingEles||o.cy.animated(),A=o.hideEdgesOnViewport&&I,L=[];if(L[o.NODE]=!c[o.NODE]&&h&&!o.clearedForMotionBlur[o.NODE]||o.clearingMotionBlur,L[o.NODE]&&(o.clearedForMotionBlur[o.NODE]=!0),L[o.DRAG]=!c[o.DRAG]&&h&&!o.clearedForMotionBlur[o.DRAG]||o.clearingMotionBlur,L[o.DRAG]&&(o.clearedForMotionBlur[o.DRAG]=!0),c[o.NODE]||n||r||L[o.NODE]){var O=h&&!L[o.NODE]&&1!==p;D(_=t||(O?o.data.bufferContexts[o.MOTIONBLUR_BUFFER_NODE]:u.contexts[o.NODE]),h&&!O?"motionBlur":void 0),A?o.drawCachedNodes(_,S.nondrag,s,z):o.drawLayeredElements(_,S.nondrag,s,z),o.debug&&o.drawDebugPoints(_,S.nondrag),n||h||(c[o.NODE]=!1)}if(!r&&(c[o.DRAG]||n||L[o.DRAG])){O=h&&!L[o.DRAG]&&1!==p;D(_=t||(O?o.data.bufferContexts[o.MOTIONBLUR_BUFFER_DRAG]:u.contexts[o.DRAG]),h&&!O?"motionBlur":void 0),A?o.drawCachedNodes(_,S.drag,s,z):o.drawCachedElements(_,S.drag,s,z),o.debug&&o.drawDebugPoints(_,S.drag),n||h||(c[o.DRAG]=!1)}if(o.showFps||!r&&c[o.SELECT_BOX]&&!n){if(D(_=t||u.contexts[o.SELECT_BOX]),1==o.selection[4]&&(o.hoverData.selecting||o.touchData.selecting)){b=o.cy.zoom();var R=m.core("selection-box-border-width").value/b;_.lineWidth=R,_.fillStyle="rgba("+m.core("selection-box-color").value[0]+","+m.core("selection-box-color").value[1]+","+m.core("selection-box-color").value[2]+","+m.core("selection-box-opacity").value+")",_.fillRect(o.selection[0],o.selection[1],o.selection[2]-o.selection[0],o.selection[3]-o.selection[1]),R>0&&(_.strokeStyle="rgba("+m.core("selection-box-border-color").value[0]+","+m.core("selection-box-border-color").value[1]+","+m.core("selection-box-border-color").value[2]+","+m.core("selection-box-opacity").value+")",_.strokeRect(o.selection[0],o.selection[1],o.selection[2]-o.selection[0],o.selection[3]-o.selection[1]))}if(u.bgActivePosistion&&!o.hoverData.selecting){b=o.cy.zoom();var V=u.bgActivePosistion;_.fillStyle="rgba("+m.core("active-bg-color").value[0]+","+m.core("active-bg-color").value[1]+","+m.core("active-bg-color").value[2]+","+m.core("active-bg-opacity").value+")",_.beginPath(),_.arc(V.x,V.y,m.core("active-bg-size").pfValue/b,0,2*Math.PI),_.fill()}var F=o.lastRedrawTime;if(o.showFps&&F){F=Math.round(F);var j=Math.round(1e3/F);_.setTransform(1,0,0,1,0,0),_.fillStyle="rgba(255, 0, 0, 0.75)",_.strokeStyle="rgba(255, 0, 0, 0.75)",_.lineWidth=1,_.fillText("1 frame = "+F+" ms = "+j+" fps",0,20);_.strokeRect(0,30,250,20),_.fillRect(0,30,250*Math.min(j/60,1),20)}n||(c[o.SELECT_BOX]=!1)}if(h&&1!==p){var q=u.contexts[o.NODE],Y=o.data.bufferCanvases[o.MOTIONBLUR_BUFFER_NODE],X=u.contexts[o.DRAG],W=o.data.bufferCanvases[o.MOTIONBLUR_BUFFER_DRAG],H=function(e,t,n){e.setTransform(1,0,0,1,0,0),n||!y?e.clearRect(0,0,o.canvasWidth,o.canvasHeight):P(e,0,0,o.canvasWidth,o.canvasHeight);var r=p;e.drawImage(t,0,0,o.canvasWidth*r,o.canvasHeight*r,0,0,o.canvasWidth,o.canvasHeight)};(c[o.NODE]||L[o.NODE])&&(H(q,Y,L[o.NODE]),c[o.NODE]=!1),(c[o.DRAG]||L[o.DRAG])&&(H(X,W,L[o.DRAG]),c[o.DRAG]=!1)}o.prevViewport=k,o.clearingMotionBlur&&(o.clearingMotionBlur=!1,o.motionBlurCleared=!0,o.motionBlur=!0),h&&(o.motionBlurTimeout=setTimeout((function(){o.motionBlurTimeout=null,o.clearedForMotionBlur[o.NODE]=!1,o.clearedForMotionBlur[o.DRAG]=!1,o.motionBlur=!1,o.clearingMotionBlur=!d,o.mbFrames=0,c[o.NODE]=!0,c[o.DRAG]=!0,o.redraw()}),100)),t||l.emit("render")};for(var Qu={drawPolygonPath:function(e,t,n,r,i,a){var o=r/2,s=i/2;e.beginPath&&e.beginPath(),e.moveTo(t+o*a[0],n+s*a[1]);for(var l=1;l0&&a>0){h.clearRect(0,0,i,a),h.globalCompositeOperation="source-over";var p=this.getCachedZSortedEles();if(e.full)h.translate(-n.x1*l,-n.y1*l),h.scale(l,l),this.drawElements(h,p),h.scale(1/l,1/l),h.translate(n.x1*l,n.y1*l);else{var f=t.pan(),g={x:f.x*l,y:f.y*l};l*=t.zoom(),h.translate(g.x,g.y),h.scale(l,l),this.drawElements(h,p),h.scale(1/l,1/l),h.translate(-g.x,-g.y)}e.bg&&(h.globalCompositeOperation="destination-over",h.fillStyle=e.bg,h.rect(0,0,i,a),h.fill())}return d},ac.png=function(e){return sc(e,this.bufferCanvasImage(e),"image/png")},ac.jpg=function(e){return sc(e,this.bufferCanvasImage(e),"image/jpeg")};var lc={nodeShapeImpl:function(e,t,n,r,i,a,o,s){switch(e){case"ellipse":return this.drawEllipsePath(t,n,r,i,a);case"polygon":return this.drawPolygonPath(t,n,r,i,a,o);case"round-polygon":return this.drawRoundPolygonPath(t,n,r,i,a,o,s);case"roundrectangle":case"round-rectangle":return this.drawRoundRectanglePath(t,n,r,i,a,s);case"cutrectangle":case"cut-rectangle":return this.drawCutRectanglePath(t,n,r,i,a,o,s);case"bottomroundrectangle":case"bottom-round-rectangle":return this.drawBottomRoundRectanglePath(t,n,r,i,a,s);case"barrel":return this.drawBarrelPath(t,n,r,i,a)}}},uc=dc,cc=dc.prototype;function dc(e){var t=this,n=t.cy.window().document;t.data={canvases:new Array(cc.CANVAS_LAYERS),contexts:new Array(cc.CANVAS_LAYERS),canvasNeedsRedraw:new Array(cc.CANVAS_LAYERS),bufferCanvases:new Array(cc.BUFFER_COUNT),bufferContexts:new Array(cc.CANVAS_LAYERS)};t.data.canvasContainer=n.createElement("div");var r=t.data.canvasContainer.style;t.data.canvasContainer.style["-webkit-tap-highlight-color"]="rgba(0,0,0,0)",r.position="relative",r.zIndex="0",r.overflow="hidden";var i=e.cy.container();i.appendChild(t.data.canvasContainer),i.style["-webkit-tap-highlight-color"]="rgba(0,0,0,0)";var a={"-webkit-user-select":"none","-moz-user-select":"-moz-none","user-select":"none","-webkit-tap-highlight-color":"rgba(0,0,0,0)","outline-style":"none"};c&&c.userAgent.match(/msie|trident|edge/i)&&(a["-ms-touch-action"]="none",a["touch-action"]="none");for(var o=0;o - + - - Waylog Dashboard + + Waylog v2 Triage + + + +
+
+
Loading dashboard…
+
- /* Hop chain */ - .hop-chain { - display: flex; - flex-direction: column; - gap: 0; - } - .hop { - position: relative; - padding: 12px 16px 12px 32px; - border-left: 2px solid var(--border); - margin-left: 8px; - } - .hop::before { - content: ""; - position: absolute; - left: -5px; - top: 16px; - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--green); - border: 2px solid var(--bg-card); - } - .hop.fail::before { - background: var(--red); - } - .hop.fail { - background: rgba(243, 18, 96, 0.05); - border-radius: var(--radius-inline); - } - .hop.root-cause { - background: rgba(243, 18, 96, 0.1); - border-left-color: var(--red); - } - .hop .hop-service { - font-weight: 500; - margin-bottom: 2px; - } - .hop .hop-detail { - font-size: 12px; - color: var(--fg-secondary); - font-family: var(--font-mono); - font-variant-numeric: tabular-nums; - } - .hop .hop-error { - font-size: 12px; - color: var(--red); - margin-top: 2px; + - - - diff --git a/internal/dashboard/static_test.go b/internal/dashboard/static_test.go new file mode 100644 index 0000000..d287abc --- /dev/null +++ b/internal/dashboard/static_test.go @@ -0,0 +1,86 @@ +package dashboard + +import ( + "io" + "io/fs" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestStaticDashboardHTML(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body, err := io.ReadAll(rec.Result().Body) + if err != nil { + t.Fatalf("read response: %v", err) + } + html := string(body) + + required := []string{ + "Waylog v2 Triage", + "fonts.googleapis.com/css2?family=Geist", + "waylog-dashboard-theme", + "data-theme", + "id=\"theme-toggle\"", + "Light theme", + "Dark theme", + "Find the failure that started the cascade.", + "First failing step", + "failure-path", + "No failures in this window.", + "http://localhost:9081/demo", + "topbar-link", + "Demo controls", + "No recent requests yet", + "Run a scenario", + "#/errors", + "#/explain", + "#/blast", + "renderSparkline", + "This dashboard requires WAYLOG_V2_READS=true", + "first observable failing step", + } + for _, needle := range required { + if !strings.Contains(html, needle) { + t.Fatalf("dashboard html missing %q", needle) + } + } + + forbidden := []string{ + "/ui/ask", + "/ui/explain", + "Chart(", + "cytoscape(", + "/v1/overview", + "/v1/routes", + "/v1/topology", + "/v1/insight", + "chart.umd.min.js", + "chartjs-plugin-annotation.min.js", + "cytoscape.min.js", + } + for _, needle := range forbidden { + if strings.Contains(html, needle) { + t.Fatalf("dashboard html still references %q", needle) + } + } +} + +func TestVendoredDashboardBundlesRemoved(t *testing.T) { + for _, name := range []string{ + "static/chart.umd.min.js", + "static/chartjs-plugin-annotation.min.js", + "static/cytoscape.min.js", + } { + if _, err := fs.Stat(staticFiles, name); err == nil { + t.Fatalf("vendored dashboard bundle still embedded: %s", name) + } + } +} diff --git a/internal/ingest/handler.go b/internal/ingest/handler.go index 6ad203e..58aeb9e 100644 --- a/internal/ingest/handler.go +++ b/internal/ingest/handler.go @@ -128,11 +128,6 @@ type Server struct { coldStore coldstore.Store planStore *PlanStore - // Dashboard rate limiter: per-IP sliding window - rateMu sync.Mutex - rateLimit map[string][]time.Time - rateCheckCount int - // Replay state — set once during startup, read by /healthz. replayStatus string // "none", "ok", "failed" replayError string @@ -247,7 +242,6 @@ func NewServer(cfg ServerConfig) *Server { graphHotWindow: cfg.GraphHotWindow, otlpEnabled: cfg.OTLPEnabled, v2ReadsEnabled: cfg.V2ReadsEnabled, - rateLimit: map[string][]time.Time{}, replayStatus: "none", } if s.sampler == nil { @@ -578,7 +572,6 @@ func (s *Server) Capabilities(w http.ResponseWriter, r *http.Request) { "max_steps_default": s.askMaxStepsDefault, "max_steps_max": s.askMaxStepsMax, }, - "ask_endpoint": "/ui/ask", "dashboard": map[string]any{ "refresh_interval_sec": s.dashboardRefreshSec, }, @@ -2131,115 +2124,6 @@ func (s *Server) replayDedupEntry(w http.ResponseWriter, r *http.Request, entry json.NewEncoder(w).Encode(entry.Data) } -// DashboardAsk handles POST /ui/ask — rate-limited ask proxy for the web dashboard. -func (s *Server) DashboardAsk(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - // Per-IP rate limit: 5 req/min - ip := clientIP(r, s.trustProxy) - if !s.checkRateLimit(ip) { - w.Header().Set("Retry-After", "60") - http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) - return - } - - if s.store == nil { - http.Error(w, "store not configured", http.StatusServiceUnavailable) - return - } - - r.Body = http.MaxBytesReader(w, r.Body, s.maxBodyBytes) - var req askRequest - dec := json.NewDecoder(r.Body) - dec.DisallowUnknownFields() - if err := dec.Decode(&req); err != nil { - http.Error(w, "invalid json", http.StatusBadRequest) - return - } - req.Prompt = strings.TrimSpace(req.Prompt) - if req.Prompt == "" { - http.Error(w, "prompt is required", http.StatusBadRequest) - return - } - - var ( - provider llm.Provider - model string - toolMode string - err error - ) - if s.askProvider != nil { - provider = s.askProvider - } else { - provider, model, toolMode, err = s.askProviderFromEnv() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - } - - registry := s.askRegistry - if registry == nil { - http.Error(w, "tool registry unavailable", http.StatusInternalServerError) - return - } - - defs := make([]llm.ToolDefinition, 0, len(registry.List())) - for _, t := range registry.List() { - defs = append(defs, llm.ToolDefinition{ - Name: t.Name, - Description: t.Description, - InputSchema: t.InputSchema, - }) - } - - // Force max_steps=5, abort on error - maxSteps := 5 - - start := time.Now() - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) - defer cancel() - - fs := &frozenStore{snap: s.store.Snapshot(), real: s.store, ts: s.traceStore} - answer, toolRecords, askErr := llm.Ask(ctx, provider, defs, llm.ToolExecutorFunc(func(ctx context.Context, name string, params json.RawMessage) (any, error) { - return registry.Call(ctx, fs, name, params) - }), req.Prompt, llm.AskOptions{MaxSteps: maxSteps, ErrorStrategy: "abort"}) - - steps := make([]askToolStep, 0, len(toolRecords)) - for i, rec := range toolRecords { - step := askToolStep{ - Index: i + 1, - Tool: rec.Name, - DurationMs: rec.DurationMs, - Params: decodeJSONRaw(rec.Params), - Error: rec.Error, - } - if rec.Result != nil { - step.Result = normalizeJSONValue(rec.Result) - } - steps = append(steps, step) - } - - if askErr != nil { - slog.Warn("dashboard ask failed", "err", askErr) - http.Error(w, "ask failed: "+askErr.Error(), http.StatusBadGateway) - return - } - - resp := askResponse{ - Answer: answer, - Model: model, - ToolMode: toolMode, - DurationMs: time.Since(start).Milliseconds(), - Steps: steps, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - func clientIP(r *http.Request, trustProxy bool) string { if trustProxy { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { @@ -2255,53 +2139,6 @@ func clientIP(r *http.Request, trustProxy bool) string { return host } -func (s *Server) checkRateLimit(ip string) bool { - now := time.Now() - cutoff := now.Add(-time.Minute) - - s.rateMu.Lock() - defer s.rateMu.Unlock() - - // Periodic global prune every 100 calls - s.rateCheckCount++ - if s.rateCheckCount%100 == 0 { - for k, v := range s.rateLimit { - allStale := true - for _, ts := range v { - if ts.After(cutoff) { - allStale = false - break - } - } - if allStale { - delete(s.rateLimit, k) - } - } - } - - // Hard bound: reject if map still too large after pruning - if len(s.rateLimit) > 10000 { - return false - } - - // Prune stale entries for this IP - timestamps := s.rateLimit[ip] - valid := timestamps[:0] - for _, ts := range timestamps { - if ts.After(cutoff) { - valid = append(valid, ts) - } - } - - if len(valid) >= 5 { - s.rateLimit[ip] = valid - return false - } - - s.rateLimit[ip] = append(valid, now) - return true -} - // Topology handles GET /v1/topology — service-to-service edges with failure counts. func (s *Server) Topology(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodOptions { @@ -2371,51 +2208,6 @@ func (s *Server) BlastRadius(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, result, meta, nil) } -// DashboardExplain handles GET /ui/explain?trace_id=X — server-side proxy for explain_request tool. -// No agent auth required (same pattern as /ui/ask). -func (s *Server) DashboardExplain(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodOptions { - return - } - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - meta := APIMeta{RequestID: RequestIDFromContext(r.Context())} - - traceID := r.URL.Query().Get("trace_id") - if traceID == "" { - respondError(w, r, http.StatusBadRequest, "INVALID_PARAMS", "trace_id is required", false, meta) - return - } - - if s.askRegistry == nil { - respondError(w, r, http.StatusServiceUnavailable, "NOT_AVAILABLE", "tools not available", false, meta) - return - } - - params, _ := json.Marshal(map[string]string{"trace_id": traceID}) - result, err := s.askRegistry.Call(r.Context(), s.store, "explain_request", params) - if err != nil { - var te *tools.ToolError - if errors.As(err, &te) { - status := http.StatusInternalServerError - if te.Code == tools.CodeNotFound { - status = http.StatusNotFound - } else if te.Code == tools.CodeInvalidParams { - status = http.StatusBadRequest - } - respondError(w, r, status, te.Code, te.Message, te.Retryable, meta) - return - } - respondError(w, r, http.StatusInternalServerError, "INTERNAL", "internal error", true, meta) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) -} - // PlanExecute handles POST /v1/plans/execute — deterministic plan execution. func (s *Server) PlanExecute(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/internal/ingest/handler_test.go b/internal/ingest/handler_test.go index a65fe3f..0c99c6c 100644 --- a/internal/ingest/handler_test.go +++ b/internal/ingest/handler_test.go @@ -1901,17 +1901,6 @@ func TestBlastRadiusEndpoint_ReturnsResult(t *testing.T) { } } -func TestDashboardExplain_MissingTraceID(t *testing.T) { - srv := makeTestServer() - req := httptest.NewRequest("GET", "/ui/explain", nil) - w := httptest.NewRecorder() - srv.DashboardExplain(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", w.Code) - } -} - func TestOverview_IncludesLatestFailedTraceID(t *testing.T) { srv := makeTestServer() req := httptest.NewRequest("GET", "/v1/overview?window=1h", nil) diff --git a/internal/ingest/v2/reader_test.go b/internal/ingest/v2/reader_test.go index e741cb4..33b9a37 100644 --- a/internal/ingest/v2/reader_test.go +++ b/internal/ingest/v2/reader_test.go @@ -102,6 +102,25 @@ func TestRecentTracesSuppressedOnlyRequiresOptIn(t *testing.T) { } } +func TestRecentTracesIncludeSuppressedAddsToRegularResults(t *testing.T) { + idx := NewRecentIndex(nil) + idx.Insert(testTraceEvent("ok", "trace-ok", "gateway", eventv2.StatusOK, testTime(0))) + idx.Insert(testTraceEvent("suppressed", "trace-suppressed", "gateway", eventv2.StatusSuppressed, testTime(1))) + reader := NewReader(idx) + filter := SearchFilter{Since: testTime(-1), Until: testTime(10)} + if got := reader.RecentTraces(filter, nil, 10); len(got.Traces) != 1 || got.Traces[0].TraceID != "trace-ok" { + t.Fatalf("default traces=%+v want only non-suppressed trace", got.Traces) + } + filter.IncludeSuppressed = true + got := reader.RecentTraces(filter, nil, 10) + if len(got.Traces) != 2 { + t.Fatalf("include suppressed traces=%+v want suppressed and non-suppressed", got.Traces) + } + if got.Traces[0].TraceID != "trace-suppressed" || got.Traces[1].TraceID != "trace-ok" { + t.Fatalf("include suppressed order=%+v", got.Traces) + } +} + func stringsJoin(parts []string) string { out := "" for i, part := range parts { diff --git a/scripts/demo-stop.sh b/scripts/demo-stop.sh index e521c35..9ed6c09 100755 --- a/scripts/demo-stop.sh +++ b/scripts/demo-stop.sh @@ -1,4 +1,22 @@ #!/usr/bin/env bash set -euo pipefail -exec ./scripts/micro-demo-stop.sh +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +STATE_DIR="${WAYLOG_DEMO_STATE_DIR:-./data/demo-state}" + +if [[ -d "$STATE_DIR" ]]; then + for pid_file in "$STATE_DIR"/*.pid; do + [[ -e "$pid_file" ]] || continue + pid="$(cat "$pid_file" 2>/dev/null || true)" + if [[ -n "${pid:-}" ]]; then + kill "$pid" >/dev/null 2>&1 || true + fi + rm -f "$pid_file" + done +fi + +./scripts/micro-demo-stop.sh >/dev/null 2>&1 || true + +echo "Waylog demo stopped." diff --git a/scripts/demo.sh b/scripts/demo.sh index aee7b91..d913e21 100755 --- a/scripts/demo.sh +++ b/scripts/demo.sh @@ -1,4 +1,157 @@ #!/usr/bin/env bash set -euo pipefail -exec ./scripts/micro-demo.sh +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +GOCACHE_DIR="${GOCACHE:-/tmp/go-build}" +STATE_DIR="${WAYLOG_DEMO_STATE_DIR:-./data/demo-state}" +LOG_DIR="${STATE_DIR}/logs" +BIN_DIR="${STATE_DIR}/bin" + +# Start from a clean local demo so the command behaves like docker-dev. +./scripts/demo-stop.sh >/dev/null 2>&1 || true +rm -rf "$STATE_DIR" +mkdir -p "$LOG_DIR" "$BIN_DIR" + +# v2 dashboard demo path: no Kafka, no bridge, no Docker. Clear legacy auth +# knobs that can conflict with the local no-login showcase. +unset KAFKA_BROKERS +unset WAYLOG_API_KEY + +export GOCACHE="$GOCACHE_DIR" +export INGEST_ADDR="${INGEST_ADDR:-127.0.0.1:8080}" +export INGEST_URL="${INGEST_URL:-http://127.0.0.1:8080}" +export CORS_ORIGIN="${CORS_ORIGIN:-http://localhost:9081}" +export WAYLOG_WRITE_KEY="${WAYLOG_WRITE_KEY:-demo}" +export DASHBOARD_AUTH="${DASHBOARD_AUTH:-off}" +if [[ "$DASHBOARD_AUTH" == "off" ]]; then + export WAYLOG_READ_KEY="" +else + export WAYLOG_READ_KEY="${WAYLOG_READ_KEY:-demo}" +fi +export WAYLOG_V2_READS="${WAYLOG_V2_READS:-true}" +export EVENT_LOG_DIR="${EVENT_LOG_DIR:-${STATE_DIR}/eventlog}" +export EVENT_LOG_V2_DIR="${EVENT_LOG_V2_DIR:-${STATE_DIR}/eventlog-v2}" +export SNAPSHOT_PATH="${SNAPSHOT_PATH:-${STATE_DIR}/graph_snapshot.json}" +export SQLITE_PATH="${SQLITE_PATH:-${STATE_DIR}/waylog.db}" + +start() { + local name="$1" + shift + local log="${LOG_DIR}/${name}.log" + nohup "$@" >"$log" 2>&1 & + echo "$!" >"${STATE_DIR}/${name}.pid" +} + +build_bin() { + local name="$1" + local pkg="$2" + go build -o "${BIN_DIR}/${name}" "$pkg" +} + +fail_service() { + local name="$1" + local target="$2" + local log="${LOG_DIR}/${name}.log" + echo "Timed out waiting for ${name} at ${target}" >&2 + echo "Log: ${log}" >&2 + if [[ -f "$log" ]]; then + echo "" >&2 + echo "Last ${name} log lines:" >&2 + tail -40 "$log" >&2 || true + fi + exit 1 +} + +process_alive() { + local name="$1" + local pid_file="${STATE_DIR}/${name}.pid" + [[ -f "$pid_file" ]] || return 1 + local pid + pid="$(cat "$pid_file" 2>/dev/null || true)" + [[ -n "$pid" ]] && kill -0 "$pid" >/dev/null 2>&1 +} + +wait_for_http() { + local url="$1" + local name="$2" + for _ in $(seq 1 80); do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + if ! process_alive "$name"; then + fail_service "$name" "$url" + fi + sleep 0.25 + done + fail_service "$name" "$url" +} + +wait_for_tcp() { + local host="$1" + local port="$2" + local name="$3" + for _ in $(seq 1 80); do + if (: >"/dev/tcp/${host}/${port}") >/dev/null 2>&1; then + return 0 + fi + if ! process_alive "$name"; then + fail_service "$name" "${host}:${port}" + fi + sleep 0.25 + done + fail_service "$name" "${host}:${port}" +} + +echo "Building local demo binaries..." +pids=() +build_bin ingest ./cmd/ingest & pids+=($!) +build_bin api-gateway ./examples/cmd/api-gateway & pids+=($!) +build_bin checkout-demo ./examples/cmd/checkout-demo & pids+=($!) +build_bin payment-demo ./examples/cmd/payment-demo & pids+=($!) +build_bin db-demo ./examples/cmd/db-demo & pids+=($!) +for pid in "${pids[@]}"; do + if ! wait "$pid"; then + echo "Build failed (pid $pid)" >&2 + exit 1 + fi +done + +start ingest "${BIN_DIR}/ingest" +wait_for_http "${INGEST_URL}/readyz" "ingest" + +start db-demo "${BIN_DIR}/db-demo" +wait_for_tcp 127.0.0.1 9084 db-demo + +start payment-demo "${BIN_DIR}/payment-demo" +wait_for_tcp 127.0.0.1 9083 payment-demo + +start checkout-demo "${BIN_DIR}/checkout-demo" +wait_for_tcp 127.0.0.1 9082 checkout-demo + +start api-gateway "${BIN_DIR}/api-gateway" +wait_for_http "http://localhost:9081/demo" "api-gateway" + +cat < Date: Mon, 4 May 2026 03:13:36 -0400 Subject: [PATCH 08/12] feat: route OTLP traces through v2 ingest Made OTLP/HTTP traces a first-class schema-2.0 emitter path. - added IngestRaw to the v2 ingest handler so non-HTTP emitters can reuse the same validation, dedupe, WAL, projection, metrics, and envelope logic as POST /v1/events - rewrote OTLP span conversion to emit schema-2.0 events instead of v1 WideEvents, including deterministic event IDs, HTTP metadata, anchors, failed steps, downstream service hints, and error logs - route /v1/otlp/v1/traces through the v2 ingest handler and remove the old v1 OTLP pipeline wiring from cmd/ingest - add OTLP-to-v2 read API integration coverage for errors, trace story, blast radius, and capabilities - update OTLP handler tests for v2 partial-success/rejection behavior - update the standalone e2e OTLP example to emit HTTP span attributes accepted by the v2 converter - clarify README/internal comments so OTLP is documented as feeding the schema-2.0 read APIs, not the old graph path Verification: - GOCACHE=/tmp/go-build go test ./internal/ingest/v2 - GOCACHE=/tmp/go-build go test ./internal/otel/... - GOCACHE=/tmp/go-build go test ./tests/integration - GOCACHE=/tmp/go-build go test ./pkg/event/v2 ./pkg/waylog/v2 ./pkg/waylog/v2/parity - cd packages/waylog-ts && npm test -- --run parity - GOCACHE=/tmp/go-build go test ./... - GOCACHE=/tmp/go-build go vet ./... - git diff --check --- README.md | 17 +- cmd/ingest/main.go | 20 +- examples/cmd/e2e-otlp/main.go | 15 ++ internal/ingest/handler.go | 8 +- internal/ingest/pipeline.go | 14 +- internal/ingest/v2/handler.go | 55 ++-- internal/ingest/v2/handler_test.go | 92 +++++++ internal/otel/convert/convert.go | 375 +++++++++++++------------- internal/otel/convert/convert_test.go | 326 ++++++++-------------- internal/otel/handler.go | 92 +++++-- internal/otel/handler_test.go | 77 ++++-- tests/integration/otlp_test.go | 245 +++++++++++------ 12 files changed, 761 insertions(+), 575 deletions(-) diff --git a/README.md b/README.md index e3038fb..c36b66b 100644 --- a/README.md +++ b/README.md @@ -71,13 +71,13 @@ Prefer Docker? Use `make docker-dev` / `make docker-down`. Prefer foreground ser ## How it works -1. **Capture** — services emit [WideEvents](docs/waylog-sdk-contract.md) via the Go or TypeScript SDK, or push OpenTelemetry spans to `/v1/otlp/v1/traces`. Every event is durably logged (WAL + fsync) before it enters the graph. -2. **Analyze** — the ingest server flattens spans into a hot in-memory graph (requests · services · errors · users · deployments). A dedicated trace store keeps span-level detail for drill-down. Deterministic tools walk the graph to answer specific questions: propagation chain, blast radius, what-changed, deploy correlation. -3. **Operator** — CLI, REST, MCP, TUI, and the embedded dashboard all query the same graph through the same tool registry. Every answer is also callable by agents as a structured tool with idempotency keys. +1. **Capture** — services emit [WideEvents](docs/waylog-sdk-contract.md) via the Go or TypeScript SDK, or push OpenTelemetry spans to `/v1/otlp/v1/traces`. Every event is durably logged (WAL + fsync) before it enters the derived read models. +2. **Analyze** — the ingest server projects completed execution segments into request, service, error, user, and trace views. Deterministic tools answer specific questions: propagation chain, blast radius, what-changed, deploy correlation. +3. **Operator** — CLI, REST, MCP, TUI, and the embedded dashboard query the same derived views through the same tool registry. Every answer is also callable by agents as a structured tool with idempotency keys. ## Get traces in -All three paths feed the same hot graph. Pick whichever matches your stack. +All three paths feed the same schema-2.0 ingest and read APIs. Pick whichever matches your stack. ### TypeScript SDK @@ -133,7 +133,7 @@ The recommended SDK path is framework middleware plus `waylog.From(ctx)` / `useL ### OTLP/HTTP traces -Point your existing OpenTelemetry collector at `http://localhost:8080/v1/otlp/v1/traces`. Protobuf bodies are accepted (gzip optional) and spans convert to WideEvents on the way in. **Phase A covers traces over HTTP.** gRPC, logs, and metrics are not yet shipping. +Point your existing OpenTelemetry collector at `http://localhost:8080/v1/otlp/v1/traces`. Protobuf bodies are accepted (gzip optional) and HTTP spans convert to schema-2.0 WideEvents on the way in, then show up in the same errors, explain, blast, and recent-trace APIs as SDK events when `WAYLOG_V2_READS=true`. **Phase A covers traces over HTTP.** gRPC, logs, and metrics are not yet shipping. ### Alternative: local ingest server (no Docker) @@ -236,12 +236,11 @@ The embedded dashboard at `/ui` is a v2 triage surface over the same read APIs a ```text Go / TS services (SDK) · OTLP/HTTP collectors - │ WideEvents (HTTP or Kafka) · OTLP traces + │ schema-2.0 WideEvents · OTLP/HTTP traces ▼ ingest server ├─ event log (append-only WAL, source of truth) - ├─ hot graph (requests · services · errors · users · deploys) - ├─ trace store (span-level detail, time-bucketed) + ├─ derived read models (errors · explain · blast · recent traces) ├─ SQLite cold store (events · deployments · causal claims) ├─ tool registry · Ask · plan execution └─ v2 dashboard · health · metrics · OpenAPI @@ -251,7 +250,7 @@ Go / TS services (SDK) · OTLP/HTTP collectors └──▶ CLI · TUI · MCP · agents ``` -The hot graph is a flattened 3-node-type model (request, service, error); span detail lives in a dedicated trace store. Events are durably logged before entering the graph — if the process crashes, replay rebuilds the graph from the WAL on next boot. +Events are durably logged before projection — if the process crashes, replay rebuilds the read models from the WAL on next boot. Durability model, retention, merge semantics, readiness policy, and counter buffer: [`docs/internals.md`](docs/internals.md). Full HTTP contract: [`docs/openapi.yaml`](docs/openapi.yaml). diff --git a/cmd/ingest/main.go b/cmd/ingest/main.go index d52acac..52c700e 100644 --- a/cmd/ingest/main.go +++ b/cmd/ingest/main.go @@ -356,25 +356,9 @@ func main() { mux.Handle("/v1/events", writeAuth(http.HandlerFunc(eventsV2.Events))) mux.Handle("/v1/events/validate", writeAuth(http.HandlerFunc(eventsV2.Validate))) - // OTLP/HTTP traces — routed through a dedicated pipeline that reuses the - // same store, builder, sampler, WAL, cold store, and SSE hub as the SDK - // path so counters and /v1/overview reflect OTLP traffic too. + // OTLP/HTTP traces reuse the same schema-2.0 WAL and projector as the SDK path. if otlpEnabled { - otlpPipeline := ingest.NewPipeline(ingest.PipelineConfig{ - Store: graphStore, - TraceStore: traceStore, - Builder: ingestServer.Builder(), - Sampler: ingestServer.Sampler(), - EventLog: ingestServer.EventLog, - ColdWriter: coldWriter, - ColdStore: coldDB, - Counters: ingestServer.Counters(), - Accepted: ingestServer.AcceptedPtr(), - Metrics: m, - Notifier: ingestServer.SSEHub(), - Validator: ingest.OTLPValidator, - }) - otlpHandler := otelhttp.NewHandler(otlpPipeline, m, maxBody) + otlpHandler := otelhttp.NewHandler(eventsV2, m, maxBody) mux.Handle("/v1/otlp/v1/traces", writeAuth(http.HandlerFunc(otlpHandler.ServeHTTP))) slog.Info("otlp enabled", "endpoint", "/v1/otlp/v1/traces") } diff --git a/examples/cmd/e2e-otlp/main.go b/examples/cmd/e2e-otlp/main.go index ab3df85..d7d9a54 100644 --- a/examples/cmd/e2e-otlp/main.go +++ b/examples/cmd/e2e-otlp/main.go @@ -50,6 +50,11 @@ func main() { EndTimeUnixNano: now + 50_000_000, Kind: tracepb.Span_SPAN_KIND_SERVER, Status: &tracepb.Status{Code: tracepb.Status_STATUS_CODE_OK}, + Attributes: []*commonpb.KeyValue{ + strAttr("http.request.method", "GET"), + strAttr("http.route", "/api"), + intAttr("http.response.status_code", 200), + }, }, { TraceId: traceBytes, @@ -60,6 +65,12 @@ func main() { EndTimeUnixNano: now + 45_000_000, Kind: tracepb.Span_SPAN_KIND_CLIENT, Status: &tracepb.Status{Code: tracepb.Status_STATUS_CODE_OK}, + Attributes: []*commonpb.KeyValue{ + strAttr("http.request.method", "GET"), + strAttr("http.route", "/db/query"), + intAttr("http.response.status_code", 200), + strAttr("peer.service", "db"), + }, }, }, }}, @@ -90,3 +101,7 @@ func main() { func strAttr(k, v string) *commonpb.KeyValue { return &commonpb.KeyValue{Key: k, Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: v}}} } + +func intAttr(k string, v int64) *commonpb.KeyValue { + return &commonpb.KeyValue{Key: k, Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_IntValue{IntValue: v}}} +} diff --git a/internal/ingest/handler.go b/internal/ingest/handler.go index 58aeb9e..bd1a4e8 100644 --- a/internal/ingest/handler.go +++ b/internal/ingest/handler.go @@ -378,15 +378,15 @@ func (s *Server) Builder() *build.Builder { return s.builder } -// Sampler returns the server's sampler so external wiring (e.g., OTLP -// pipeline construction in main.go) can share the same sampling policy. +// Sampler returns the server's sampler so external schema-1.x pipeline wiring +// can share the same sampling policy. func (s *Server) Sampler() *sampler.Sampler { return s.sampler } // SSEHub returns the server's SSE hub for reuse as a Pipeline Notifier. func (s *Server) SSEHub() *SSEHub { return s.sseHub } -// Counters returns the shared unsampled windowed counters. Used so the -// OTLP pipeline contributes to the same windowed error rate as the SDK path. +// Counters returns the shared unsampled windowed counters for schema-1.x +// pipeline wiring. func (s *Server) Counters() *unsampledCounters { return &s.counters } // AcceptedPtr returns a pointer to the accepted-events atomic counter so the diff --git a/internal/ingest/pipeline.go b/internal/ingest/pipeline.go index 696e1c5..d2dc98c 100644 --- a/internal/ingest/pipeline.go +++ b/internal/ingest/pipeline.go @@ -25,8 +25,8 @@ type Notifier interface { } // Validator is the per-event validation function the Pipeline applies before -// any durable work. SDK pipeline uses event.WideEvent.Validate; OTLP pipeline -// uses OTLPValidator which suppresses user.id-only failures. +// any durable work. The default path uses event.WideEvent.Validate; specialized +// callers may provide a narrower validator. type Validator func(ev *event.WideEvent) error // PipelineConfig holds all dependencies for a Pipeline instance. @@ -47,8 +47,8 @@ type PipelineConfig struct { Validator Validator } -// Pipeline is the protocol-agnostic ingest core shared by the SDK HTTP handler -// and the OTLP handler. Order of operations per event: +// Pipeline is the schema-1.x ingest core for old graph-derived APIs. Order of +// operations per event: // // validate → WAL → counters → cold store → deployment upsert → sample → // build → merge graph + tracestore → notify (once per batch) @@ -240,9 +240,9 @@ func (p *Pipeline) IngestBatch(ctx context.Context, events []*event.WideEvent) ( return result, nil } -// OTLPValidator is the Validator the OTLP pipeline uses. It runs the full -// event.WideEvent.Validate and then suppresses the result if the ONLY failing -// field is user.id — because OTLP has no standard way to carry end-user id. +// OTLPValidator is kept for callers that ingest schema-1.x events converted +// from telemetry without end-user identity. It runs event.WideEvent.Validate +// and then suppresses the result if the ONLY failing field is user.id. // Any other validation failure (including multi-field errors that happen to // include user.id) is returned unchanged. func OTLPValidator(ev *event.WideEvent) error { diff --git a/internal/ingest/v2/handler.go b/internal/ingest/v2/handler.go index ca32b92..1155424 100644 --- a/internal/ingest/v2/handler.go +++ b/internal/ingest/v2/handler.go @@ -1,7 +1,9 @@ package ingestv2 import ( + "context" "encoding/json" + "errors" "log/slog" "net/http" "strings" @@ -71,6 +73,11 @@ func New(cfg Config) (*Handler, error) { return &Handler{schema: sch, metrics: m, dedup: dedup, wal: cfg.WAL, project: projector}, nil } +var ( + errDurabilityUnavailable = errors.New("durability unavailable") + errProjectionUnavailable = errors.New("projection unavailable") +) + var rejectionReasons = []string{ ReasonInvalidJSON, ReasonSchemaValidationFailed, @@ -114,12 +121,34 @@ func (h *Handler) handle(w http.ResponseWriter, r *http.Request, durable bool) { h.writeError(w, reqErr) return } - if h.metrics != nil { - h.metrics.IngestBatchSize.Observe(float64(len(parsed.events))) + env, err := h.IngestRaw(r.Context(), parsed.events, durable) + if err != nil { + switch { + case errors.Is(err, errDurabilityUnavailable): + http.Error(w, "durability unavailable", http.StatusServiceUnavailable) + case errors.Is(err, errProjectionUnavailable): + http.Error(w, "projection unavailable", http.StatusServiceUnavailable) + default: + http.Error(w, "ingest unavailable", http.StatusServiceUnavailable) + } + return } + writeEnvelope(w, http.StatusOK, env) +} +// IngestRaw validates already-framed schema-2.0 JSON events and optionally +// writes accepted events through the same dedupe, WAL, and projection path used +// by POST /v1/events. Per-event validation failures are represented in the +// returned envelope; infrastructure failures are returned as errors. +func (h *Handler) IngestRaw(ctx context.Context, eventBodies [][]byte, durable bool) (IngestEnvelope, error) { env := newEnvelope() - for i, eventBody := range parsed.events { + if h.metrics != nil { + h.metrics.IngestBatchSize.Observe(float64(len(eventBodies))) + } + for i, eventBody := range eventBodies { + if err := ctx.Err(); err != nil { + return env, err + } validateStart := time.Now() result := h.validateEvent(i, eventBody) if h.metrics != nil { @@ -136,8 +165,7 @@ func (h *Handler) handle(w http.ResponseWriter, r *http.Request, durable bool) { } if h.wal == nil { h.recordRejected(ReasonDurabilityUnavailable) - http.Error(w, "durability unavailable", http.StatusServiceUnavailable) - return + return env, errDurabilityUnavailable } var duplicate bool var err error @@ -150,8 +178,7 @@ func (h *Handler) handle(w http.ResponseWriter, r *http.Request, durable bool) { } if err != nil { h.recordRejected(ReasonDurabilityUnavailable) - http.Error(w, "durability unavailable", http.StatusServiceUnavailable) - return + return env, errDurabilityUnavailable } if duplicate { env.Duplicate++ @@ -160,15 +187,15 @@ func (h *Handler) handle(w http.ResponseWriter, r *http.Request, durable bool) { } continue } - if !h.projectEvent(w, result.eventID, result.event) { - return + if err := h.projectEvent(result.eventID, result.event); err != nil { + return env, err } if h.metrics != nil { h.metrics.EventsAccepted.Inc() } env.Accepted++ } - writeEnvelope(w, http.StatusOK, env) + return env, nil } type validationResult struct { @@ -236,8 +263,7 @@ func (h *Handler) writeWAL(eventBody []byte) error { return err } -func (h *Handler) projectEvent(w http.ResponseWriter, eventID string, ev *eventv2.Event) (ok bool) { - ok = false +func (h *Handler) projectEvent(eventID string, ev *eventv2.Event) (err error) { start := time.Now() defer func() { if h.metrics != nil { @@ -251,8 +277,7 @@ func (h *Handler) projectEvent(w http.ResponseWriter, eventID string, ev *eventv h.dedup.Remove(eventID) } slog.Error("ingestv2: projection panic", "event_id", eventID, "panic", recovered) - http.Error(w, "projection unavailable", http.StatusServiceUnavailable) - ok = false + err = errProjectionUnavailable } }() if h.project != nil { @@ -261,7 +286,7 @@ func (h *Handler) projectEvent(w http.ResponseWriter, eventID string, ev *eventv if h.metrics != nil { h.metrics.V2EventsProjected.Inc() } - return true + return nil } func writeEnvelope(w http.ResponseWriter, status int, env IngestEnvelope) { diff --git a/internal/ingest/v2/handler_test.go b/internal/ingest/v2/handler_test.go index bc1d483..c6d3552 100644 --- a/internal/ingest/v2/handler_test.go +++ b/internal/ingest/v2/handler_test.go @@ -3,7 +3,9 @@ package ingestv2 import ( "bytes" "compress/gzip" + "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -463,6 +465,96 @@ func TestEventsProjectionPanicReturns503AndRollsBackDedup(t *testing.T) { } } +func TestIngestRawMatchesHTTPEnvelope(t *testing.T) { + index := NewRecentIndex(nil) + h, wal := newTestHandlerWithIndex(t, nil, index) + raw := validEventMap("00000000-0000-4000-8000-000000000002") + delete(raw, "service") + bodies := [][]byte{ + []byte(validEventJSON("00000000-0000-4000-8000-000000000001")), + []byte(validEventJSON("00000000-0000-4000-8000-000000000001")), + []byte(mustJSON(t, raw)), + } + + env, err := h.IngestRaw(context.Background(), bodies, true) + if err != nil { + t.Fatalf("IngestRaw: %v", err) + } + if env.Accepted != 1 || env.Duplicate != 1 || len(env.Rejected) != 1 { + t.Fatalf("env=%+v", env) + } + if env.Rejected[0].Index != 2 || env.Rejected[0].Reason != ReasonSchemaValidationFailed { + t.Fatalf("rejected=%+v", env.Rejected[0]) + } + if wal.Count() != 1 { + t.Fatalf("wal writes=%d want 1", wal.Count()) + } + if index.Sizes().Events != 1 { + t.Fatalf("indexed events=%d want 1", index.Sizes().Events) + } +} + +func TestIngestRawDryRunDoesNotMutateDurableState(t *testing.T) { + dedup := NewDedup(10, nil) + wal := &fakeWAL{} + index := NewRecentIndex(nil) + h := newTestHandlerWithConfig(t, Config{Dedup: dedup, WAL: wal, Index: index}) + + env, err := h.IngestRaw(context.Background(), [][]byte{[]byte(validEventJSON("00000000-0000-4000-8000-000000000001"))}, false) + if err != nil { + t.Fatalf("IngestRaw: %v", err) + } + if env.Accepted != 1 || env.Duplicate != 0 || len(env.Rejected) != 0 { + t.Fatalf("env=%+v", env) + } + if wal.Count() != 0 { + t.Fatalf("wal writes=%d want 0", wal.Count()) + } + if dedup.Size() != 0 { + t.Fatalf("dedup size=%d want 0", dedup.Size()) + } + if index.Sizes().Events != 0 { + t.Fatalf("indexed events=%d want 0", index.Sizes().Events) + } +} + +func TestIngestRawWALFailureReturnsError(t *testing.T) { + wal := &fakeWAL{failAt: 1} + h := newTestHandlerWithWAL(t, nil, wal) + + env, err := h.IngestRaw(context.Background(), [][]byte{[]byte(validEventJSON("00000000-0000-4000-8000-000000000001"))}, true) + if !errors.Is(err, errDurabilityUnavailable) { + t.Fatalf("err=%v want durability unavailable", err) + } + if env.Accepted != 0 || len(env.Rejected) != 0 { + t.Fatalf("env=%+v", env) + } + if wal.Count() != 0 { + t.Fatalf("wal writes=%d want 0", wal.Count()) + } +} + +func TestIngestRawProjectionFailureReturnsErrorAndRollsBackDedup(t *testing.T) { + dedup := NewDedup(10, nil) + h := newTestHandlerWithConfig(t, Config{ + Dedup: dedup, + WAL: &fakeWAL{}, + Project: panicProjector{}, + }) + eventID := "00000000-0000-4000-8000-000000000001" + + env, err := h.IngestRaw(context.Background(), [][]byte{[]byte(validEventJSON(eventID))}, true) + if !errors.Is(err, errProjectionUnavailable) { + t.Fatalf("err=%v want projection unavailable", err) + } + if env.Accepted != 0 || len(env.Rejected) != 0 { + t.Fatalf("env=%+v", env) + } + if dedup.Seen(eventID) { + t.Fatal("dedup entry should be rolled back") + } +} + func newTestHandler(t *testing.T, m *metrics.Metrics) (*Handler, *fakeWAL) { t.Helper() wal := &fakeWAL{} diff --git a/internal/otel/convert/convert.go b/internal/otel/convert/convert.go index c6891ee..cf918d4 100644 --- a/internal/otel/convert/convert.go +++ b/internal/otel/convert/convert.go @@ -1,15 +1,16 @@ // Package convert translates OTLP ExportTraceServiceRequest payloads into -// WideEvents. It is a pure function package — no I/O, no dependencies beyond -// pkg/event and the OTLP proto types — so it can be reused by a future -// OpenTelemetry Collector exporter without dragging in the ingest stack. +// schema-2.0 WideEvents. It is a pure function package — no I/O, no ingest +// dependency — so it can be reused by future OTLP entrypoints. package convert import ( + "crypto/sha256" "encoding/hex" + "fmt" "strings" "time" - "github.com/sssmaran/WaylogCLI/pkg/event" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" commonpb "go.opentelemetry.io/proto/otlp/common/v1" resourcepb "go.opentelemetry.io/proto/otlp/resource/v1" @@ -24,6 +25,7 @@ const ( DropMissingService DropReason = "missing_service" DropMissingTraceID DropReason = "missing_trace_id" DropInvalidSpan DropReason = "invalid_span" + DropUnsupportedKind DropReason = "unsupported_kind" DropFutureTimestamp DropReason = "future_timestamp" ) @@ -33,68 +35,31 @@ type DropEntry struct { Reason DropReason } -// Result is what SpansToEvents returns. Events are not yet validated; the -// caller runs them through the ingest pipeline's validator. +// Result is what SpansToEvents returns. Events are not yet written; callers run +// them through the schema-2.0 ingest handler. type Result struct { - Events []*event.WideEvent + Events []*eventv2.Event Dropped int Drops []DropEntry } -// spanKey is the (trace_id, span_id) key used to resolve caller service -// across the resource_spans boundary in a single request. -type spanKey struct { - TraceID string - SpanID string -} - -// SpansToEvents converts an OTLP ExportTraceServiceRequest into WideEvents. -// Best-effort: spans that can't be meaningfully converted are dropped and -// counted. The parent-span index is scoped to this request only. +// SpansToEvents converts OTLP spans into schema-2.0 WideEvents. HTTP spans are +// supported in this slice; non-HTTP spans are dropped until their v2 semantics +// are explicitly defined. func SpansToEvents(req *coltracepb.ExportTraceServiceRequest) Result { var res Result if req == nil { return res } - - // First pass: build (trace_id, span_id) → service_name index so child - // spans can discover their parent's service for caller_service. - idx := make(map[spanKey]string) - for _, rs := range req.GetResourceSpans() { - svc := resourceAttr(rs.GetResource(), "service.name") - if svc == "" { - continue - } - for _, ss := range rs.GetScopeSpans() { - for _, span := range ss.GetSpans() { - tid := hex.EncodeToString(span.GetTraceId()) - sid := hex.EncodeToString(span.GetSpanId()) - if tid != "" && sid != "" { - idx[spanKey{TraceID: tid, SpanID: sid}] = svc - } - } - } - } - - // Second pass: convert each span. for _, rs := range req.GetResourceSpans() { rc := extractResourceContext(rs.GetResource()) if rc.service == "" { - for _, ss := range rs.GetScopeSpans() { - for _, span := range ss.GetSpans() { - res.Dropped++ - res.Drops = append(res.Drops, DropEntry{ - SpanName: span.GetName(), - Reason: DropMissingService, - }) - } - } + dropResourceSpans(&res, rs, DropMissingService) continue } - for _, ss := range rs.GetScopeSpans() { for _, span := range ss.GetSpans() { - ev, drop := convertSpan(span, rc, idx) + ev, drop := convertSpan(span, rc) if drop != nil { res.Dropped++ res.Drops = append(res.Drops, *drop) @@ -104,10 +69,18 @@ func SpansToEvents(req *coltracepb.ExportTraceServiceRequest) Result { } } } - return res } +func dropResourceSpans(res *Result, rs *tracepb.ResourceSpans, reason DropReason) { + for _, ss := range rs.GetScopeSpans() { + for _, span := range ss.GetSpans() { + res.Dropped++ + res.Drops = append(res.Drops, DropEntry{SpanName: span.GetName(), Reason: reason}) + } + } +} + type resourceContext struct { service string version string @@ -119,7 +92,6 @@ func extractResourceContext(r *resourcepb.Resource) resourceContext { service: resourceAttr(r, "service.name"), version: resourceAttr(r, "service.version"), } - // Prefer new semconv (deployment.environment.name) over legacy (deployment.environment). rc.env = resourceAttr(r, "deployment.environment.name") if rc.env == "" { rc.env = resourceAttr(r, "deployment.environment") @@ -130,99 +102,90 @@ func extractResourceContext(r *resourcepb.Resource) resourceContext { return rc } -func convertSpan(span *tracepb.Span, rc resourceContext, idx map[spanKey]string) (*event.WideEvent, *DropEntry) { +func convertSpan(span *tracepb.Span, rc resourceContext) (*eventv2.Event, *DropEntry) { if allZeros(span.GetTraceId()) { return nil, &DropEntry{SpanName: span.GetName(), Reason: DropMissingTraceID} } - // Reject empty/zero span IDs: the graph builder skips span records when - // Request.SpanID is empty, which would leave trace drill-down incomplete - // for a request the overview now counts as accepted. if allZeros(span.GetSpanId()) { return nil, &DropEntry{SpanName: span.GetName(), Reason: DropInvalidSpan} } + attrs := spanAttrs(span) + statusCode, success, hasHTTP := deriveHTTPOutcome(span, attrs) + if !hasHTTP { + return nil, &DropEntry{SpanName: span.GetName(), Reason: DropUnsupportedKind} + } + traceID := hex.EncodeToString(span.GetTraceId()) spanID := hex.EncodeToString(span.GetSpanId()) - parentSpanID := "" if !allZeros(span.GetParentSpanId()) { parentSpanID = hex.EncodeToString(span.GetParentSpanId()) } - attrs := spanAttrs(span) - statusCode, success, hasHTTP := deriveOutcome(span, attrs) - - kind := "internal" - if hasHTTP { - kind = "http" - } else if _, ok := attrs["rpc.system"]; ok { - kind = "rpc" - } - - eventName := rc.service + ".request" - if !success { - eventName = rc.service + ".error" - } - - var errCtx *event.ErrorContext - if !success { - errCtx = extractError(span) + start := time.Unix(0, int64(span.GetStartTimeUnixNano())).UTC() + end := time.Unix(0, int64(span.GetEndTimeUnixNano())).UTC() + if end.Before(start) { + end = start } + durationMS := int64(end.Sub(start) / time.Millisecond) - httpMethod, _ := attrs["http.request.method"].(string) - httpRoute, _ := attrs["http.route"].(string) - - var downstream string - if span.GetKind() == tracepb.Span_SPAN_KIND_CLIENT || span.GetKind() == tracepb.Span_SPAN_KIND_PRODUCER { - downstream = resolveDownstream(attrs) + stepName := stepName(span, attrs) + errorCode, errorReason := errorInfo(span, attrs, statusCode) + step := eventv2.Step{ + Name: stepName, + SpanID: spanID, + StartMS: 0, + DurationMS: durationMS, + Status: eventv2.StepStatusOK, } - - var caller string - if parentSpanID != "" { - if parentSvc, ok := idx[spanKey{TraceID: traceID, SpanID: parentSpanID}]; ok && parentSvc != rc.service { - caller = parentSvc - } + if downstream := downstreamFromSpan(span, attrs); downstream != nil { + step.Downstream = downstream } - startNano := span.GetStartTimeUnixNano() - endNano := span.GetEndTimeUnixNano() - var latencyMs int64 - if endNano > startNano { - latencyMs = int64((endNano - startNano) / 1_000_000) - } + status := eventv2.StatusOK + var anchor *eventv2.Anchor + var errors []eventv2.ErrorRef + var logs []eventv2.Log + if !success { + status = eventv2.StatusError + step.Status = eventv2.StepStatusError + step.Error = &eventv2.StepError{Code: errorCode, Reason: errorReason, Cause: "otlp"} + anchor = &eventv2.Anchor{Step: stepName, ErrorCode: errorCode, Kind: "otlp"} + errors = []eventv2.ErrorRef{{Code: errorCode, Reason: errorReason}} + logs = errorLogs(span, errorCode, errorReason) + } + + return &eventv2.Event{ + SchemaVersion: eventv2.SchemaVersion2, + EventID: deterministicEventID(traceID, spanID), + TsStart: start, + TsEnd: end, + DurationMS: durationMS, + Kind: "http", + Service: rc.service, + Env: rc.env, + Version: rc.version, + TraceID: traceID, + SpanID: spanID, + ParentSpanID: parentSpanID, + Status: status, + Anchor: anchor, + Steps: []eventv2.Step{step}, + Logs: logs, + Fields: fields(span, attrs, statusCode), + Errors: errors, + }, nil +} - ev := &event.WideEvent{ - SchemaVersion: event.SchemaVersion, - EventName: eventName, - Timestamp: time.Unix(0, int64(startNano)), - Request: event.RequestContext{ - TraceID: traceID, - SpanID: spanID, - ParentSpanID: parentSpanID, - HTTPMethod: httpMethod, - RouteTemplate: httpRoute, - }, - System: event.SystemContext{ - Service: rc.service, - Version: rc.version, - Env: rc.env, - CallerService: caller, - DownstreamService: downstream, - }, - Outcome: event.OutcomeContext{ - Success: success, - StatusCode: statusCode, - Kind: kind, - }, - Error: errCtx, - Metrics: event.MetricsContext{LatencyMs: latencyMs}, - } - return ev, nil +func deterministicEventID(traceID, spanID string) string { + sum := sha256.Sum256([]byte(traceID + ":" + spanID)) + b := append([]byte(nil), sum[:16]...) + b[6] = (b[6] & 0x0f) | 0x50 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) } -// deriveOutcome follows the spec: HTTP status code first, then gRPC, then -// OTLP status. Any OTLP ERROR or exception event forces failure even when -// HTTP indicated success. -func deriveOutcome(span *tracepb.Span, attrs map[string]any) (statusCode int, success bool, hasHTTP bool) { +func deriveHTTPOutcome(span *tracepb.Span, attrs map[string]any) (statusCode int, success bool, hasHTTP bool) { if v, ok := attrs["http.response.status_code"]; ok { if code, ok := v.(int64); ok && code > 0 { statusCode = int(code) @@ -234,21 +197,13 @@ func deriveOutcome(span *tracepb.Span, attrs map[string]any) (statusCode int, su return } } - if _, ok := attrs["http.request.method"]; ok { hasHTTP = true } - - if v, ok := attrs["rpc.grpc.status_code"]; ok { - if code, ok := v.(int64); ok { - statusCode = grpcToHTTP(int(code)) - success = code == 0 - return - } + if _, ok := attrs["http.route"]; ok { + hasHTTP = true } - - status := span.GetStatus() - if status != nil && status.GetCode() == tracepb.Status_STATUS_CODE_ERROR { + if isOTLPError(span) { return 500, false, hasHTTP } return 200, true, hasHTTP @@ -266,78 +221,120 @@ func isOTLPError(span *tracepb.Span) bool { return false } -func extractError(span *tracepb.Span) *event.ErrorContext { +func stepName(span *tracepb.Span, attrs map[string]any) string { + if v, ok := attrs["waylog.step"].(string); ok && v != "" { + return v + } + if v, ok := attrs["http.route"].(string); ok && v != "" { + return v + } + if span.GetName() != "" { + return span.GetName() + } + return "otlp.span" +} + +func errorInfo(span *tracepb.Span, attrs map[string]any, statusCode int) (code, reason string) { + if v, ok := attrs["waylog.error_code"].(string); ok && v != "" { + return v, errorReason(span) + } for _, e := range span.GetEvents() { if e.GetName() != "exception" { continue } - code := eventAttr(e, "exception.type") - msg := eventAttr(e, "exception.message") - if code != "" { - return &event.ErrorContext{Code: code, Message: msg} + if code := eventAttr(e, "exception.type"); code != "" { + return code, eventAttr(e, "exception.message") + } + } + if statusCode > 0 { + return fmt.Sprintf("HTTP_%d", statusCode), errorReason(span) + } + return "OTLP_ERROR", errorReason(span) +} + +func errorReason(span *tracepb.Span) string { + for _, e := range span.GetEvents() { + if e.GetName() == "exception" { + if msg := eventAttr(e, "exception.message"); msg != "" { + return msg + } } } if s := span.GetStatus(); s != nil && s.GetMessage() != "" { - return &event.ErrorContext{Code: "OTEL_ERROR", Message: s.GetMessage()} + return s.GetMessage() + } + return "otlp span reported error" +} + +func errorLogs(span *tracepb.Span, code, reason string) []eventv2.Log { + msg := reason + if msg == "" { + msg = code + } + return []eventv2.Log{{ + TsOffsetMS: 0, + Level: eventv2.LogLevelError, + Msg: msg, + Fields: map[string]any{"error_code": code}, + }} +} + +func downstreamFromSpan(span *tracepb.Span, attrs map[string]any) *eventv2.Downstream { + if span.GetKind() != tracepb.Span_SPAN_KIND_CLIENT && span.GetKind() != tracepb.Span_SPAN_KIND_PRODUCER { + return nil + } + service := resolveDownstream(attrs) + if service == "" { + return nil + } + return &eventv2.Downstream{ + Service: service, + Endpoint: downstreamEndpoint(attrs), + Kind: "http", } - return &event.ErrorContext{Code: "OTEL_ERROR"} } func resolveDownstream(attrs map[string]any) string { - if v, ok := attrs["peer.service"].(string); ok && v != "" { - return v + for _, key := range []string{"peer.service", "server.address", "net.peer.name"} { + if v, ok := attrs[key].(string); ok && v != "" { + return stripHostPort(v) + } } - if v, ok := attrs["server.address"].(string); ok && v != "" { - host := strings.TrimPrefix(v, "http://") - host = strings.TrimPrefix(host, "https://") - if i := strings.LastIndex(host, ":"); i > 0 { - host = host[:i] + return "" +} + +func downstreamEndpoint(attrs map[string]any) string { + for _, key := range []string{"http.route", "url.path", "http.target"} { + if v, ok := attrs[key].(string); ok && v != "" { + return v } - return host } return "" } -// grpcToHTTP maps gRPC status codes to approximate HTTP equivalents so the -// outcome status code stays consistent across transports. -func grpcToHTTP(code int) int { - switch code { - case 0: - return 200 - case 1: - return 499 - case 2: - return 500 - case 3: - return 400 - case 4: - return 504 - case 5: - return 404 - case 6: - return 409 - case 7: - return 403 - case 8: - return 429 - case 9: - return 400 - case 10: - return 409 - case 11: - return 400 - case 12: - return 501 - case 13: - return 500 - case 14: - return 503 - case 15: - return 500 - case 16: - return 401 - default: - return 500 +func stripHostPort(v string) string { + host := strings.TrimPrefix(v, "http://") + host = strings.TrimPrefix(host, "https://") + if i := strings.LastIndex(host, ":"); i > 0 { + host = host[:i] + } + return host +} + +func fields(span *tracepb.Span, attrs map[string]any, statusCode int) map[string]any { + httpFields := map[string]any{"status": statusCode} + if v, ok := attrs["http.request.method"].(string); ok && v != "" { + httpFields["method"] = v + } + if v, ok := attrs["http.route"].(string); ok && v != "" { + httpFields["route"] = v + } + return map[string]any{ + "http": httpFields, + "otel": map[string]any{ + "span_name": span.GetName(), + "span_kind": span.GetKind().String(), + }, } } diff --git a/internal/otel/convert/convert_test.go b/internal/otel/convert/convert_test.go index d48fa97..e8fa082 100644 --- a/internal/otel/convert/convert_test.go +++ b/internal/otel/convert/convert_test.go @@ -3,7 +3,7 @@ package convert import ( "testing" - "github.com/sssmaran/WaylogCLI/pkg/event" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" commonpb "go.opentelemetry.io/proto/otlp/common/v1" resourcepb "go.opentelemetry.io/proto/otlp/resource/v1" @@ -24,6 +24,7 @@ func minimalRequest(serviceName string, traceID, spanID []byte) *coltracepb.Expo Resource: &resourcepb.Resource{ Attributes: []*commonpb.KeyValue{ strAttr("service.name", serviceName), + strAttr("service.version", "1.2.3"), strAttr("deployment.environment", "prod"), }, }, @@ -32,9 +33,14 @@ func minimalRequest(serviceName string, traceID, spanID []byte) *coltracepb.Expo TraceId: traceID, SpanId: spanID, Name: "GET /api/users", - StartTimeUnixNano: 1000000000, - EndTimeUnixNano: 1050000000, - Status: &tracepb.Status{Code: tracepb.Status_STATUS_CODE_OK}, + StartTimeUnixNano: 1_000_000_000, + EndTimeUnixNano: 1_050_000_000, + Attributes: []*commonpb.KeyValue{ + strAttr("http.request.method", "GET"), + strAttr("http.route", "/api/users"), + intAttr("http.response.status_code", 200), + }, + Status: &tracepb.Status{Code: tracepb.Status_STATUS_CODE_OK}, }}, }}, }}, @@ -49,7 +55,7 @@ func hexSpanID() []byte { return []byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11} } -func TestSpansToEvents_MinimalSpan(t *testing.T) { +func TestSpansToEvents_MinimalHTTPSpan(t *testing.T) { req := minimalRequest("checkout", hexTraceID(), hexSpanID()) res := SpansToEvents(req) @@ -60,289 +66,183 @@ func TestSpansToEvents_MinimalSpan(t *testing.T) { t.Fatalf("expected 1 event, got %d", len(res.Events)) } ev := res.Events[0] + requireValidV2(t, ev) - if ev.System.Service != "checkout" { - t.Errorf("service = %q, want checkout", ev.System.Service) + if ev.SchemaVersion != eventv2.SchemaVersion2 { + t.Errorf("schema_version=%q", ev.SchemaVersion) + } + if ev.EventID == "" { + t.Fatal("event_id is empty") } - if ev.System.Env != "prod" { - t.Errorf("env = %q, want prod", ev.System.Env) + if ev.Service != "checkout" { + t.Errorf("service=%q want checkout", ev.Service) } - if ev.Request.TraceID != "aabbccddeeff00112233445566778899" { - t.Errorf("trace_id = %q", ev.Request.TraceID) + if ev.Env != "prod" { + t.Errorf("env=%q want prod", ev.Env) } - if ev.Request.SpanID != "aabbccddeeff0011" { - t.Errorf("span_id = %q", ev.Request.SpanID) + if ev.Version != "1.2.3" { + t.Errorf("version=%q want 1.2.3", ev.Version) } - if ev.Outcome.StatusCode != 200 { - t.Errorf("status_code = %d, want 200", ev.Outcome.StatusCode) + if ev.TraceID != "aabbccddeeff00112233445566778899" { + t.Errorf("trace_id=%q", ev.TraceID) } - if !ev.Outcome.Success { - t.Error("expected success=true") + if ev.SpanID != "aabbccddeeff0011" { + t.Errorf("span_id=%q", ev.SpanID) } - if ev.EventName != "checkout.request" { - t.Errorf("event_name = %q, want checkout.request", ev.EventName) + if ev.Kind != "http" { + t.Errorf("kind=%q want http", ev.Kind) } - if ev.Metrics.LatencyMs != 50 { - t.Errorf("latency_ms = %d, want 50", ev.Metrics.LatencyMs) + if ev.Status != eventv2.StatusOK { + t.Errorf("status=%q want ok", ev.Status) } - if ev.User.ID != "" { - t.Errorf("expected empty user.id, got %q", ev.User.ID) + if ev.DurationMS != 50 { + t.Errorf("duration_ms=%d want 50", ev.DurationMS) } - if ev.SchemaVersion != event.SchemaVersion { - t.Errorf("schema_version = %q", ev.SchemaVersion) + if len(ev.Steps) != 1 || ev.Steps[0].Name != "/api/users" || ev.Steps[0].Status != eventv2.StepStatusOK { + t.Fatalf("steps=%+v", ev.Steps) } - if ev.Outcome.Kind != "internal" { - t.Errorf("kind = %q, want internal", ev.Outcome.Kind) + if ev.Anchor != nil { + t.Fatalf("anchor=%+v want nil", ev.Anchor) } } func TestSpansToEvents_MissingServiceName(t *testing.T) { req := minimalRequest("", hexTraceID(), hexSpanID()) - // Keep only the env attribute. - req.ResourceSpans[0].Resource.Attributes = req.ResourceSpans[0].Resource.Attributes[1:] + req.ResourceSpans[0].Resource.Attributes = req.ResourceSpans[0].Resource.Attributes[2:] res := SpansToEvents(req) - if res.Dropped != 1 { - t.Errorf("expected 1 dropped, got %d", res.Dropped) - } - if len(res.Events) != 0 { - t.Errorf("expected 0 events, got %d", len(res.Events)) + if res.Dropped != 1 || len(res.Events) != 0 { + t.Fatalf("dropped=%d events=%d", res.Dropped, len(res.Events)) } if len(res.Drops) != 1 || res.Drops[0].Reason != DropMissingService { - t.Errorf("expected DropMissingService, got %v", res.Drops) + t.Errorf("drops=%+v", res.Drops) } } func TestSpansToEvents_MissingTraceID(t *testing.T) { req := minimalRequest("svc", nil, hexSpanID()) res := SpansToEvents(req) - if res.Dropped != 1 { - t.Errorf("expected 1 dropped, got %d", res.Dropped) - } - if len(res.Drops) != 1 || res.Drops[0].Reason != DropMissingTraceID { - t.Errorf("expected DropMissingTraceID, got %v", res.Drops) + if res.Dropped != 1 || len(res.Drops) != 1 || res.Drops[0].Reason != DropMissingTraceID { + t.Fatalf("res=%+v", res) } } func TestSpansToEvents_MissingSpanID(t *testing.T) { req := minimalRequest("svc", hexTraceID(), nil) res := SpansToEvents(req) - if res.Dropped != 1 { - t.Errorf("expected 1 dropped, got %d", res.Dropped) - } - if len(res.Events) != 0 { - t.Errorf("expected 0 events, got %d", len(res.Events)) - } - if len(res.Drops) != 1 || res.Drops[0].Reason != DropInvalidSpan { - t.Errorf("expected DropInvalidSpan, got %v", res.Drops) + if res.Dropped != 1 || len(res.Events) != 0 || len(res.Drops) != 1 || res.Drops[0].Reason != DropInvalidSpan { + t.Fatalf("res=%+v", res) } } -func TestSpansToEvents_ZeroSpanID(t *testing.T) { - zeros := []byte{0, 0, 0, 0, 0, 0, 0, 0} - req := minimalRequest("svc", hexTraceID(), zeros) +func TestSpansToEvents_DropsNonHTTPSpan(t *testing.T) { + req := minimalRequest("worker", hexTraceID(), hexSpanID()) + req.ResourceSpans[0].ScopeSpans[0].Spans[0].Attributes = nil res := SpansToEvents(req) - if len(res.Events) != 0 { - t.Errorf("expected 0 events, got %d", len(res.Events)) - } - if len(res.Drops) != 1 || res.Drops[0].Reason != DropInvalidSpan { - t.Errorf("expected DropInvalidSpan, got %v", res.Drops) + if res.Dropped != 1 || len(res.Events) != 0 || len(res.Drops) != 1 || res.Drops[0].Reason != DropUnsupportedKind { + t.Fatalf("res=%+v", res) } } -func TestSpansToEvents_ZeroSpans(t *testing.T) { - req := &coltracepb.ExportTraceServiceRequest{} +func TestSpansToEvents_HTTP404IsOK(t *testing.T) { + req := minimalRequest("api", hexTraceID(), hexSpanID()) + span := req.ResourceSpans[0].ScopeSpans[0].Spans[0] + span.Attributes = []*commonpb.KeyValue{intAttr("http.response.status_code", 404), strAttr("http.request.method", "GET")} res := SpansToEvents(req) - if len(res.Events) != 0 || res.Dropped != 0 { - t.Errorf("expected empty result for zero spans") + ev := res.Events[0] + requireValidV2(t, ev) + if ev.Status != eventv2.StatusOK { + t.Fatalf("status=%q want ok", ev.Status) + } + if ev.Anchor != nil { + t.Fatalf("anchor=%+v want nil", ev.Anchor) } } -func TestSpansToEvents_HTTPStatus500(t *testing.T) { +func TestSpansToEvents_HTTP500CreatesAnchorStepAndLog(t *testing.T) { req := minimalRequest("payment", hexTraceID(), hexSpanID()) span := req.ResourceSpans[0].ScopeSpans[0].Spans[0] span.Attributes = []*commonpb.KeyValue{ intAttr("http.response.status_code", 500), strAttr("http.request.method", "POST"), - strAttr("http.route", "/api/pay"), + strAttr("http.route", "/charge"), } - span.Status = &tracepb.Status{Code: tracepb.Status_STATUS_CODE_ERROR, Message: "internal error"} - span.Events = []*tracepb.Span_Event{{ - Name: "exception", - Attributes: []*commonpb.KeyValue{ - strAttr("exception.type", "PaymentError"), - strAttr("exception.message", "insufficient funds"), - }, - }} + span.Status = &tracepb.Status{Code: tracepb.Status_STATUS_CODE_ERROR, Message: "upstream gateway 5xx"} res := SpansToEvents(req) if len(res.Events) != 1 { - t.Fatalf("expected 1 event, got %d", len(res.Events)) + t.Fatalf("events=%d", len(res.Events)) } ev := res.Events[0] - if ev.Outcome.Success { - t.Error("expected failure") - } - if ev.Outcome.StatusCode != 500 { - t.Errorf("status_code = %d, want 500", ev.Outcome.StatusCode) - } - if ev.EventName != "payment.error" { - t.Errorf("event_name = %q, want payment.error", ev.EventName) + requireValidV2(t, ev) + if ev.Status != eventv2.StatusError { + t.Fatalf("status=%q want error", ev.Status) } - if ev.Error == nil || ev.Error.Code != "PaymentError" { - t.Errorf("error.code = %v", ev.Error) + if ev.Anchor == nil || ev.Anchor.Step != "/charge" || ev.Anchor.ErrorCode != "HTTP_500" { + t.Fatalf("anchor=%+v", ev.Anchor) } - if ev.Error.Message != "insufficient funds" { - t.Errorf("error.message = %q", ev.Error.Message) + if len(ev.Steps) != 1 || ev.Steps[0].Status != eventv2.StepStatusError || ev.Steps[0].Error == nil || ev.Steps[0].Error.Code != "HTTP_500" { + t.Fatalf("steps=%+v", ev.Steps) } - if ev.Request.HTTPMethod != "POST" { - t.Errorf("http_method = %q", ev.Request.HTTPMethod) - } - if ev.Request.RouteTemplate != "/api/pay" { - t.Errorf("route_template = %q", ev.Request.RouteTemplate) - } - if ev.Outcome.Kind != "http" { - t.Errorf("kind = %q, want http", ev.Outcome.Kind) + if len(ev.Logs) != 1 || ev.Logs[0].Level != eventv2.LogLevelError || ev.Logs[0].Msg != "upstream gateway 5xx" { + t.Fatalf("logs=%+v", ev.Logs) } } -func TestSpansToEvents_HTTP404IsSuccess(t *testing.T) { - req := minimalRequest("api", hexTraceID(), hexSpanID()) +func TestSpansToEvents_WaylogAttrsOverrideErrorFamilyAndDownstream(t *testing.T) { + req := minimalRequest("checkout", hexTraceID(), hexSpanID()) span := req.ResourceSpans[0].ScopeSpans[0].Spans[0] - span.Attributes = []*commonpb.KeyValue{intAttr("http.response.status_code", 404)} - span.Status = &tracepb.Status{Code: tracepb.Status_STATUS_CODE_UNSET} - res := SpansToEvents(req) - ev := res.Events[0] - if !ev.Outcome.Success { - t.Error("HTTP 404 should be success per SDK semantics") - } - if ev.EventName != "api.request" { - t.Errorf("event_name = %q, want api.request", ev.EventName) + span.Kind = tracepb.Span_SPAN_KIND_CLIENT + span.Attributes = []*commonpb.KeyValue{ + intAttr("http.response.status_code", 502), + strAttr("http.request.method", "POST"), + strAttr("http.route", "/charge"), + strAttr("waylog.step", "payment.charge"), + strAttr("waylog.error_code", "PMT_502"), + strAttr("peer.service", "payment"), } -} + span.Status = &tracepb.Status{Code: tracepb.Status_STATUS_CODE_ERROR, Message: "payment unavailable"} -func TestSpansToEvents_OTLPErrorForcesFailure(t *testing.T) { - req := minimalRequest("svc", hexTraceID(), hexSpanID()) - span := req.ResourceSpans[0].ScopeSpans[0].Spans[0] - span.Attributes = []*commonpb.KeyValue{intAttr("http.response.status_code", 200)} - span.Status = &tracepb.Status{Code: tracepb.Status_STATUS_CODE_ERROR, Message: "forced error"} res := SpansToEvents(req) ev := res.Events[0] - if ev.Outcome.Success { - t.Error("OTLP ERROR should force failure even with HTTP 200") + requireValidV2(t, ev) + if ev.Anchor == nil || ev.Anchor.Step != "payment.charge" || ev.Anchor.ErrorCode != "PMT_502" { + t.Fatalf("anchor=%+v", ev.Anchor) } - if ev.Error == nil || ev.Error.Code != "OTEL_ERROR" { - t.Errorf("expected OTEL_ERROR code, got %v", ev.Error) + step := ev.Steps[0] + if step.Name != "payment.charge" || step.Downstream == nil || step.Downstream.Service != "payment" { + t.Fatalf("step=%+v", step) } -} - -func TestSpansToEvents_CallerService(t *testing.T) { - parentSpanID := []byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88} - childSpanID := []byte{0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00} - traceID := hexTraceID() - - req := &coltracepb.ExportTraceServiceRequest{ - ResourceSpans: []*tracepb.ResourceSpans{ - { - Resource: &resourcepb.Resource{Attributes: []*commonpb.KeyValue{ - strAttr("service.name", "gateway"), - strAttr("deployment.environment", "prod"), - }}, - ScopeSpans: []*tracepb.ScopeSpans{{Spans: []*tracepb.Span{{ - TraceId: traceID, SpanId: parentSpanID, Name: "gateway-op", - StartTimeUnixNano: 1000000000, EndTimeUnixNano: 1050000000, - Status: &tracepb.Status{Code: tracepb.Status_STATUS_CODE_OK}, - }}}}, - }, - { - Resource: &resourcepb.Resource{Attributes: []*commonpb.KeyValue{ - strAttr("service.name", "checkout"), - strAttr("deployment.environment", "prod"), - }}, - ScopeSpans: []*tracepb.ScopeSpans{{Spans: []*tracepb.Span{{ - TraceId: traceID, SpanId: childSpanID, ParentSpanId: parentSpanID, Name: "checkout-op", - StartTimeUnixNano: 1010000000, EndTimeUnixNano: 1040000000, - Status: &tracepb.Status{Code: tracepb.Status_STATUS_CODE_OK}, - }}}}, - }, - }, - } - - res := SpansToEvents(req) - if len(res.Events) != 2 { - t.Fatalf("expected 2 events, got %d", len(res.Events)) - } - - var checkoutEv *event.WideEvent - for _, ev := range res.Events { - if ev.System.Service == "checkout" { - checkoutEv = ev - } - } - if checkoutEv == nil { - t.Fatal("checkout event not found") - } - if checkoutEv.System.CallerService != "gateway" { - t.Errorf("caller_service = %q, want gateway", checkoutEv.System.CallerService) + if len(ev.Errors) != 1 || ev.Errors[0].Code != "PMT_502" { + t.Fatalf("errors=%+v", ev.Errors) } } -func TestSpansToEvents_CallerService_SameService_NoSelfCall(t *testing.T) { - parentSpanID := []byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88} - childSpanID := []byte{0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00} - traceID := hexTraceID() - - req := &coltracepb.ExportTraceServiceRequest{ - ResourceSpans: []*tracepb.ResourceSpans{{ - Resource: &resourcepb.Resource{Attributes: []*commonpb.KeyValue{ - strAttr("service.name", "svc"), - strAttr("deployment.environment", "prod"), - }}, - ScopeSpans: []*tracepb.ScopeSpans{{Spans: []*tracepb.Span{ - {TraceId: traceID, SpanId: parentSpanID, Name: "op1", StartTimeUnixNano: 1000000000, EndTimeUnixNano: 1050000000, Status: &tracepb.Status{Code: tracepb.Status_STATUS_CODE_OK}}, - {TraceId: traceID, SpanId: childSpanID, ParentSpanId: parentSpanID, Name: "op2", StartTimeUnixNano: 1010000000, EndTimeUnixNano: 1040000000, Status: &tracepb.Status{Code: tracepb.Status_STATUS_CODE_OK}}, - }}}, - }}, - } - - res := SpansToEvents(req) - for _, ev := range res.Events { - if ev.System.CallerService != "" { - t.Errorf("expected no caller_service for same-service span, got %q", ev.System.CallerService) - } - } -} - -func TestSpansToEvents_DownstreamService(t *testing.T) { - req := minimalRequest("gateway", hexTraceID(), hexSpanID()) +func TestSpansToEvents_ExceptionTypeIsFallbackErrorCode(t *testing.T) { + req := minimalRequest("checkout", hexTraceID(), hexSpanID()) span := req.ResourceSpans[0].ScopeSpans[0].Spans[0] - span.Kind = tracepb.Span_SPAN_KIND_CLIENT - span.Attributes = []*commonpb.KeyValue{strAttr("peer.service", "checkout")} - res := SpansToEvents(req) - ev := res.Events[0] - if ev.System.DownstreamService != "checkout" { - t.Errorf("downstream_service = %q, want checkout", ev.System.DownstreamService) - } -} + span.Status = &tracepb.Status{Code: tracepb.Status_STATUS_CODE_ERROR} + span.Events = []*tracepb.Span_Event{{ + Name: "exception", + Attributes: []*commonpb.KeyValue{ + strAttr("exception.type", "CheckoutError"), + strAttr("exception.message", "cart validation failed"), + }, + }} -func TestSpansToEvents_DeploymentEnvironmentName(t *testing.T) { - req := minimalRequest("svc", hexTraceID(), hexSpanID()) - req.ResourceSpans[0].Resource.Attributes = []*commonpb.KeyValue{ - strAttr("service.name", "svc"), - strAttr("deployment.environment.name", "staging"), - strAttr("deployment.environment", "prod"), - } res := SpansToEvents(req) ev := res.Events[0] - if ev.System.Env != "staging" { - t.Errorf("env = %q, want staging (deployment.environment.name takes precedence)", ev.System.Env) + requireValidV2(t, ev) + if ev.Anchor == nil || ev.Anchor.ErrorCode != "CheckoutError" { + t.Fatalf("anchor=%+v", ev.Anchor) + } + if len(ev.Logs) != 1 || ev.Logs[0].Msg != "cart validation failed" { + t.Fatalf("logs=%+v", ev.Logs) } } -func TestSpansToEvents_RouteTemplateOnlyFromHTTPRoute(t *testing.T) { - req := minimalRequest("svc", hexTraceID(), hexSpanID()) - res := SpansToEvents(req) - ev := res.Events[0] - if ev.Request.RouteTemplate != "" { - t.Errorf("route_template should be empty without http.route attr, got %q", ev.Request.RouteTemplate) +func requireValidV2(t *testing.T, ev *eventv2.Event) { + t.Helper() + if err := eventv2.Validate("../../../docs/schema/v2.0.json", ev); err != nil { + t.Fatalf("event must validate against schema: %v\n%+v", err, ev) } } diff --git a/internal/otel/handler.go b/internal/otel/handler.go index f65b6eb..321c344 100644 --- a/internal/otel/handler.go +++ b/internal/otel/handler.go @@ -1,11 +1,13 @@ // Package otel contains the HTTP handler that accepts OTLP/HTTP traces and -// feeds them through the shared ingest Pipeline. The protobuf decoding and +// feeds them through the schema-2.0 ingest handler. The protobuf decoding and // span→WideEvent conversion live in internal/otel/convert; this package only // deals with the HTTP transport contract. package otel import ( "compress/gzip" + "context" + "encoding/json" "io" "log/slog" "mime" @@ -13,28 +15,29 @@ import ( "strings" "time" - "github.com/sssmaran/WaylogCLI/internal/ingest" + ingestv2 "github.com/sssmaran/WaylogCLI/internal/ingest/v2" "github.com/sssmaran/WaylogCLI/internal/metrics" "github.com/sssmaran/WaylogCLI/internal/otel/convert" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" "google.golang.org/protobuf/proto" ) // Handler serves POST /v1/otlp/v1/traces. It decodes the protobuf body, -// converts spans to WideEvents, and delegates ingestion to the shared -// Pipeline. The handler responds with an ExportTraceServiceResponse per +// converts spans to WideEvents, and delegates ingestion to the v2 ingest +// handler. The handler responds with an ExportTraceServiceResponse per // the OTLP/HTTP spec — partial_success is set when any span was dropped // by conversion or rejected by validation. type Handler struct { - pipeline *ingest.Pipeline + v2Ingest *ingestv2.Handler metrics *metrics.Metrics maxBodyBytes int64 } // NewHandler constructs an OTLP traces handler. -func NewHandler(pipeline *ingest.Pipeline, m *metrics.Metrics, maxBodyBytes int64) *Handler { +func NewHandler(v2Ingest *ingestv2.Handler, m *metrics.Metrics, maxBodyBytes int64) *Handler { return &Handler{ - pipeline: pipeline, + v2Ingest: v2Ingest, metrics: m, maxBodyBytes: maxBodyBytes, } @@ -110,10 +113,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { futureCutoff := time.Now().Add(5 * time.Minute) kept := convResult.Events[:0] for _, ev := range convResult.Events { - if ev.Timestamp.After(futureCutoff) { + if ev.TsStart.After(futureCutoff) { convResult.Dropped++ convResult.Drops = append(convResult.Drops, convert.DropEntry{ - SpanName: ev.EventName, + SpanName: spanName(ev), Reason: convert.DropFutureTimestamp, }) continue @@ -131,28 +134,49 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - var batchResult ingest.BatchResult - if len(convResult.Events) > 0 && h.pipeline != nil { - batchResult, err = h.pipeline.ValidateAndIngestBatch(r.Context(), convResult.Events) + var env ingestv2.IngestEnvelope + if len(convResult.Events) > 0 { + if h.v2Ingest == nil { + slog.Error("otlp: v2 ingest handler unavailable") + if h.metrics != nil { + h.metrics.OTLPInfraFailures.Inc() + } + h.respondStatus(w, "5xx", http.StatusServiceUnavailable, "infrastructure error") + return + } + bodies, err := marshalEvents(convResult.Events) if err != nil { - slog.Error("otlp: pipeline infrastructure failure", "err", err) + slog.Error("otlp: marshal converted v2 events", "err", err) if h.metrics != nil { h.metrics.OTLPInfraFailures.Inc() } h.respondStatus(w, "5xx", http.StatusServiceUnavailable, "infrastructure error") return } - if h.metrics != nil && batchResult.Rejected > 0 { - h.metrics.OTLPValidationRejects.Add(float64(batchResult.Rejected)) + env, err = h.v2Ingest.IngestRaw(r.Context(), bodies, true) + if err != nil { + if err == context.Canceled || err == context.DeadlineExceeded { + h.respondStatus(w, "5xx", http.StatusServiceUnavailable, "request canceled") + return + } + slog.Error("otlp: v2 ingest infrastructure failure", "err", err) + if h.metrics != nil { + h.metrics.OTLPInfraFailures.Inc() + } + h.respondStatus(w, "5xx", http.StatusServiceUnavailable, "infrastructure error") + return + } + if h.metrics != nil && len(env.Rejected) > 0 { + h.metrics.OTLPValidationRejects.Add(float64(len(env.Rejected))) } } resp := &coltracepb.ExportTraceServiceResponse{} - totalRejected := int64(convResult.Dropped + batchResult.Rejected) + totalRejected := int64(convResult.Dropped + len(env.Rejected)) if totalRejected > 0 { resp.PartialSuccess = &coltracepb.ExportTracePartialSuccess{ RejectedSpans: totalRejected, - ErrorMessage: buildPartialSuccessMessage(convResult.Drops, batchResult.Errors), + ErrorMessage: buildPartialSuccessMessage(convResult.Drops, env.Rejected), } } @@ -178,9 +202,33 @@ func (h *Handler) respondStatus(w http.ResponseWriter, bucket string, code int, http.Error(w, msg, code) } +func marshalEvents(events []*eventv2.Event) ([][]byte, error) { + out := make([][]byte, 0, len(events)) + for _, ev := range events { + raw, err := json.Marshal(ev) + if err != nil { + return nil, err + } + out = append(out, raw) + } + return out, nil +} + +func spanName(ev *eventv2.Event) string { + if ev == nil || ev.Fields == nil { + return "" + } + otelFields, ok := ev.Fields["otel"].(map[string]any) + if !ok { + return "" + } + name, _ := otelFields["span_name"].(string) + return name +} + // buildPartialSuccessMessage produces a short human-readable summary of the // first few drops and validation rejects, capped to avoid runaway size. -func buildPartialSuccessMessage(drops []convert.DropEntry, errs []ingest.EventError) string { +func buildPartialSuccessMessage(drops []convert.DropEntry, rejects []ingestv2.RejectedEvent) string { const limit = 5 var parts []string for i, d := range drops { @@ -193,11 +241,15 @@ func buildPartialSuccessMessage(drops []convert.DropEntry, errs []ingest.EventEr } parts = append(parts, msg) } - for i, e := range errs { + for i, e := range rejects { if i >= limit { break } - parts = append(parts, e.Reason) + msg := e.Reason + if e.Detail != "" { + msg += " (" + e.Detail + ")" + } + parts = append(parts, msg) } return strings.Join(parts, "; ") } diff --git a/internal/otel/handler_test.go b/internal/otel/handler_test.go index 6aed510..dab8afc 100644 --- a/internal/otel/handler_test.go +++ b/internal/otel/handler_test.go @@ -5,14 +5,11 @@ import ( "compress/gzip" "net/http" "net/http/httptest" + "sync" "testing" "time" - "github.com/sssmaran/WaylogCLI/internal/graph/build" - "github.com/sssmaran/WaylogCLI/internal/graph/store" - "github.com/sssmaran/WaylogCLI/internal/ingest" - "github.com/sssmaran/WaylogCLI/internal/sampler" - "github.com/sssmaran/WaylogCLI/pkg/event" + ingestv2 "github.com/sssmaran/WaylogCLI/internal/ingest/v2" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" commonpb "go.opentelemetry.io/proto/otlp/common/v1" resourcepb "go.opentelemetry.io/proto/otlp/resource/v1" @@ -20,19 +17,27 @@ import ( "google.golang.org/protobuf/proto" ) -func testPipeline() *ingest.Pipeline { - return ingest.NewPipeline(ingest.PipelineConfig{ - Store: store.NewStore(), - Builder: build.NewBuilder(), - Sampler: sampler.New(sampler.Config{HappySampleRatePct: 100}), - Validator: ingest.OTLPValidator, +func testV2Ingest(t *testing.T) *ingestv2.Handler { + t.Helper() + h, err := ingestv2.New(ingestv2.Config{ + Dedup: ingestv2.NewDedup(ingestv2.DefaultDedupCapacity, nil), + WAL: &fakeWAL{}, + Index: ingestv2.NewRecentIndex(nil), }) + if err != nil { + t.Fatalf("ingestv2.New: %v", err) + } + return h } func strAttr(k, v string) *commonpb.KeyValue { return &commonpb.KeyValue{Key: k, Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: v}}} } +func intAttr(k string, v int64) *commonpb.KeyValue { + return &commonpb.KeyValue{Key: k, Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_IntValue{IntValue: v}}} +} + func validOTLPRequest() *coltracepb.ExportTraceServiceRequest { return &coltracepb.ExportTraceServiceRequest{ ResourceSpans: []*tracepb.ResourceSpans{{ @@ -47,7 +52,12 @@ func validOTLPRequest() *coltracepb.ExportTraceServiceRequest { Name: "test-op", StartTimeUnixNano: 1000000000, EndTimeUnixNano: 1050000000, - Status: &tracepb.Status{Code: tracepb.Status_STATUS_CODE_OK}, + Attributes: []*commonpb.KeyValue{ + strAttr("http.request.method", "GET"), + strAttr("http.route", "/test"), + intAttr("http.response.status_code", 200), + }, + Status: &tracepb.Status{Code: tracepb.Status_STATUS_CODE_OK}, }}, }}, }}, @@ -68,7 +78,7 @@ func postOTLP(handler http.Handler, body []byte, contentType, contentEncoding st } func TestHandler_HappyPath(t *testing.T) { - h := NewHandler(testPipeline(), nil, 1<<20) + h := NewHandler(testV2Ingest(t), nil, 1<<20) body, _ := proto.Marshal(validOTLPRequest()) rr := postOTLP(h, body, "application/x-protobuf", "") if rr.Code != 200 { @@ -84,7 +94,7 @@ func TestHandler_HappyPath(t *testing.T) { } func TestHandler_GzipCompressed(t *testing.T) { - h := NewHandler(testPipeline(), nil, 1<<20) + h := NewHandler(testV2Ingest(t), nil, 1<<20) body, _ := proto.Marshal(validOTLPRequest()) var buf bytes.Buffer gw := gzip.NewWriter(&buf) @@ -97,7 +107,7 @@ func TestHandler_GzipCompressed(t *testing.T) { } func TestHandler_ContentTypeWithParams(t *testing.T) { - h := NewHandler(testPipeline(), nil, 1<<20) + h := NewHandler(testV2Ingest(t), nil, 1<<20) body, _ := proto.Marshal(validOTLPRequest()) rr := postOTLP(h, body, "application/x-protobuf; charset=utf-8", "") if rr.Code != 200 { @@ -106,7 +116,7 @@ func TestHandler_ContentTypeWithParams(t *testing.T) { } func TestHandler_WrongContentType(t *testing.T) { - h := NewHandler(testPipeline(), nil, 1<<20) + h := NewHandler(testV2Ingest(t), nil, 1<<20) rr := postOTLP(h, []byte("{}"), "application/json", "") if rr.Code != http.StatusUnsupportedMediaType { t.Errorf("status = %d, want 415", rr.Code) @@ -114,7 +124,7 @@ func TestHandler_WrongContentType(t *testing.T) { } func TestHandler_UnsupportedContentEncoding(t *testing.T) { - h := NewHandler(testPipeline(), nil, 1<<20) + h := NewHandler(testV2Ingest(t), nil, 1<<20) body, _ := proto.Marshal(validOTLPRequest()) rr := postOTLP(h, body, "application/x-protobuf", "deflate") if rr.Code != http.StatusUnsupportedMediaType { @@ -123,7 +133,7 @@ func TestHandler_UnsupportedContentEncoding(t *testing.T) { } func TestHandler_WrongMethod(t *testing.T) { - h := NewHandler(testPipeline(), nil, 1<<20) + h := NewHandler(testV2Ingest(t), nil, 1<<20) req := httptest.NewRequest(http.MethodGet, "/v1/otlp/v1/traces", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -133,7 +143,7 @@ func TestHandler_WrongMethod(t *testing.T) { } func TestHandler_MalformedProtobuf(t *testing.T) { - h := NewHandler(testPipeline(), nil, 1<<20) + h := NewHandler(testV2Ingest(t), nil, 1<<20) rr := postOTLP(h, []byte("not protobuf"), "application/x-protobuf", "") if rr.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", rr.Code) @@ -141,7 +151,7 @@ func TestHandler_MalformedProtobuf(t *testing.T) { } func TestHandler_BodyTooLarge(t *testing.T) { - h := NewHandler(testPipeline(), nil, 10) + h := NewHandler(testV2Ingest(t), nil, 10) body, _ := proto.Marshal(validOTLPRequest()) rr := postOTLP(h, body, "application/x-protobuf", "") if rr.Code != http.StatusRequestEntityTooLarge { @@ -150,7 +160,7 @@ func TestHandler_BodyTooLarge(t *testing.T) { } func TestHandler_EmptyRequest(t *testing.T) { - h := NewHandler(testPipeline(), nil, 1<<20) + h := NewHandler(testV2Ingest(t), nil, 1<<20) body, _ := proto.Marshal(&coltracepb.ExportTraceServiceRequest{}) rr := postOTLP(h, body, "application/x-protobuf", "") if rr.Code != 200 { @@ -158,8 +168,17 @@ func TestHandler_EmptyRequest(t *testing.T) { } } +func TestHandler_MissingV2IngestReturns503ForConvertedSpans(t *testing.T) { + h := NewHandler(nil, nil, 1<<20) + body, _ := proto.Marshal(validOTLPRequest()) + rr := postOTLP(h, body, "application/x-protobuf", "") + if rr.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503", rr.Code) + } +} + func TestHandler_FutureTimestampDropped(t *testing.T) { - h := NewHandler(testPipeline(), nil, 1<<20) + h := NewHandler(testV2Ingest(t), nil, 1<<20) req := validOTLPRequest() // Stamp the span 10 minutes in the future — should be dropped with // partial_success rather than skewing recent traces / overview. @@ -188,6 +207,14 @@ func TestHandler_FutureTimestampDropped(t *testing.T) { } } -// Silence unused import; event types are used by helper functions in other -// tests within this package in the future. -var _ = event.SchemaVersion +type fakeWAL struct { + mu sync.Mutex + writes [][]byte +} + +func (w *fakeWAL) WriteRaw(line []byte) error { + w.mu.Lock() + defer w.mu.Unlock() + w.writes = append(w.writes, append([]byte(nil), line...)) + return nil +} diff --git a/tests/integration/otlp_test.go b/tests/integration/otlp_test.go index 3d1885a..20d9933 100644 --- a/tests/integration/otlp_test.go +++ b/tests/integration/otlp_test.go @@ -2,15 +2,17 @@ package integration import ( "bytes" - "encoding/json" "net/http" "net/http/httptest" + "sync" "testing" + "time" - graphstore "github.com/sssmaran/WaylogCLI/internal/graph/store" "github.com/sssmaran/WaylogCLI/internal/ingest" + ingestv2 "github.com/sssmaran/WaylogCLI/internal/ingest/v2" otelhttp "github.com/sssmaran/WaylogCLI/internal/otel" - "github.com/sssmaran/WaylogCLI/internal/sampler" + apiv2 "github.com/sssmaran/WaylogCLI/pkg/api/v2" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" commonpb "go.opentelemetry.io/proto/otlp/common/v1" resourcepb "go.opentelemetry.io/proto/otlp/resource/v1" @@ -18,71 +20,80 @@ import ( "google.golang.org/protobuf/proto" ) -// otlpStrAttr is a small constructor for OTLP string attributes. func otlpStrAttr(k, v string) *commonpb.KeyValue { return &commonpb.KeyValue{Key: k, Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: v}}} } -// resourceSpansFor builds a single ResourceSpans for one service with one span. -func resourceSpansFor(service string, traceID, spanID, parentSpanID []byte, name string, startNs, endNs uint64) *tracepb.ResourceSpans { - span := &tracepb.Span{ - TraceId: traceID, - SpanId: spanID, - Name: name, - StartTimeUnixNano: startNs, - EndTimeUnixNano: endNs, - Status: &tracepb.Status{Code: tracepb.Status_STATUS_CODE_OK}, - } - if len(parentSpanID) > 0 { - span.ParentSpanId = parentSpanID - } +func otlpIntAttr(k string, v int64) *commonpb.KeyValue { + return &commonpb.KeyValue{Key: k, Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_IntValue{IntValue: v}}} +} + +func resourceSpansFor(service string, span *tracepb.Span) *tracepb.ResourceSpans { return &tracepb.ResourceSpans{ Resource: &resourcepb.Resource{Attributes: []*commonpb.KeyValue{ otlpStrAttr("service.name", service), otlpStrAttr("deployment.environment", "prod"), }}, - ScopeSpans: []*tracepb.ScopeSpans{{ - Spans: []*tracepb.Span{span}, - }}, + ScopeSpans: []*tracepb.ScopeSpans{{Spans: []*tracepb.Span{span}}}, } } -// newOTLPHandler builds an OTLP handler that shares store/builder/sampler/etc. -// with the provided integration server, mirroring what cmd/ingest/main.go does. -func newOTLPHandler(t *testing.T, srv *integrationServer, store *graphstore.Store) *otelhttp.Handler { +type otlpV2Stack struct { + otlp *otelhttp.Handler + read *ingestv2.ReadHandler + caps *ingest.Server +} + +func newOTLPV2Stack(t *testing.T) otlpV2Stack { t.Helper() - // Use a 100% sampler so the test is deterministic regardless of the - // HAPPY_SAMPLE_RATE_PCT env default the integration server picks up. - // Notifier is left nil — the integration server isn't running an SSE hub. - pipe := ingest.NewPipeline(ingest.PipelineConfig{ - Store: store, - Builder: srv.Builder(), - Sampler: sampler.New(sampler.Config{HappySampleRatePct: 100}), - EventLog: srv.EventLog, - Counters: srv.Counters(), - Accepted: srv.AcceptedPtr(), - Validator: ingest.OTLPValidator, + index := ingestv2.NewRecentIndex(nil) + v2, err := ingestv2.New(ingestv2.Config{ + Dedup: ingestv2.NewDedup(ingestv2.DefaultDedupCapacity, nil), + WAL: &fakeV2WAL{}, + Index: index, }) - return otelhttp.NewHandler(pipe, nil, 1<<20) + if err != nil { + t.Fatalf("ingestv2.New: %v", err) + } + return otlpV2Stack{ + otlp: otelhttp.NewHandler(v2, nil, 1<<20), + read: ingestv2.NewReadHandler(ingestv2.NewReader(index), nil, 24*time.Hour), + caps: ingest.NewServer(ingest.ServerConfig{OTLPEnabled: true, V2ReadsEnabled: true}), + } } -// TestOTLP_EndToEnd posts a 3-service OTLP trace and verifies it appears -// in /v1/traces/recent and that /v1/capabilities advertises otlp.http_traces. +// TestOTLP_EndToEnd posts a 4-span OTLP checkout cascade and verifies it +// surfaces through the schema-2.0 read APIs used by CLI/dashboard. func TestOTLP_EndToEnd(t *testing.T) { - srv, _, _ := newIntegrationServer(t) - srv.SetOTLPEnabled(true) - handler := newOTLPHandler(t, srv, srv.Store()) - + stack := newOTLPV2Stack(t) traceID := []byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99} gatewaySpan := []byte{0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80} checkoutSpan := []byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88} + checkoutPaymentSpan := []byte{0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28} paymentSpan := []byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11} + base := uint64(time.Now().Add(-time.Second).UnixNano()) req := &coltracepb.ExportTraceServiceRequest{ ResourceSpans: []*tracepb.ResourceSpans{ - resourceSpansFor("api-gateway", traceID, gatewaySpan, nil, "GET /checkout", 1_000_000_000, 1_080_000_000), - resourceSpansFor("checkout", traceID, checkoutSpan, gatewaySpan, "checkout-op", 1_010_000_000, 1_070_000_000), - resourceSpansFor("payment", traceID, paymentSpan, checkoutSpan, "payment-op", 1_020_000_000, 1_060_000_000), + resourceSpansFor("api-gateway", httpSpan(traceID, gatewaySpan, nil, "POST /purchase", base, base+90_000_000, 502, + otlpStrAttr("http.request.method", "POST"), + otlpStrAttr("http.route", "/purchase"), + )), + resourceSpansFor("checkout", httpSpan(traceID, checkoutSpan, gatewaySpan, "POST /checkout", base+10_000_000, base+80_000_000, 502, + otlpStrAttr("http.request.method", "POST"), + otlpStrAttr("http.route", "/checkout"), + )), + resourceSpansFor("checkout", httpSpan(traceID, checkoutPaymentSpan, checkoutSpan, "POST payment", base+30_000_000, base+70_000_000, 502, + otlpStrAttr("http.request.method", "POST"), + otlpStrAttr("http.route", "/charge"), + otlpStrAttr("waylog.step", "payment.charge"), + otlpStrAttr("waylog.error_code", "PMT_502"), + otlpStrAttr("peer.service", "payment"), + )), + resourceSpansFor("payment", httpSpan(traceID, paymentSpan, checkoutPaymentSpan, "POST /charge", base+35_000_000, base+65_000_000, 200, + otlpStrAttr("http.request.method", "POST"), + otlpStrAttr("http.route", "/charge"), + )), }, } @@ -90,11 +101,10 @@ func TestOTLP_EndToEnd(t *testing.T) { if err != nil { t.Fatalf("marshal: %v", err) } - httpReq := httptest.NewRequest(http.MethodPost, "/v1/otlp/v1/traces", bytes.NewReader(body)) httpReq.Header.Set("Content-Type", "application/x-protobuf") rr := httptest.NewRecorder() - handler.ServeHTTP(rr, httpReq) + stack.otlp.ServeHTTP(rr, httpReq) if rr.Code != http.StatusOK { t.Fatalf("OTLP POST: expected 200, got %d; body: %s", rr.Code, rr.Body.String()) } @@ -103,51 +113,136 @@ func TestOTLP_EndToEnd(t *testing.T) { t.Fatalf("decode response: %v", err) } if resp.PartialSuccess != nil && resp.PartialSuccess.RejectedSpans != 0 { - t.Fatalf("unexpected partial success: rejected=%d msg=%s", - resp.PartialSuccess.RejectedSpans, resp.PartialSuccess.ErrorMessage) + t.Fatalf("unexpected partial success: rejected=%d msg=%s", resp.PartialSuccess.RejectedSpans, resp.PartialSuccess.ErrorMessage) } - // Verify trace surfaced in /v1/traces/recent. - rw := httpGET(t, srv.RecentTraces, "/v1/traces/recent?limit=10") + wantTraceID := "aabbccddeeff00112233445566778899" + assertErrorsContainPaymentFamily(t, stack.read) + assertStoryShowsPaymentFailure(t, stack.read, wantTraceID) + assertBlastShowsPaymentImpact(t, stack.read) + assertCapabilitiesAdvertiseOTLPAndV2Reads(t, stack.caps) +} + +func httpSpan(traceID, spanID, parentSpanID []byte, name string, start, end uint64, status int64, attrs ...*commonpb.KeyValue) *tracepb.Span { + allAttrs := append([]*commonpb.KeyValue{otlpIntAttr("http.response.status_code", status)}, attrs...) + code := tracepb.Status_STATUS_CODE_OK + msg := "" + if status >= 500 { + code = tracepb.Status_STATUS_CODE_ERROR + msg = "upstream gateway 5xx" + } + span := &tracepb.Span{ + TraceId: traceID, + SpanId: spanID, + ParentSpanId: parentSpanID, + Name: name, + Kind: tracepb.Span_SPAN_KIND_SERVER, + StartTimeUnixNano: start, + EndTimeUnixNano: end, + Attributes: allAttrs, + Status: &tracepb.Status{Code: code, Message: msg}, + } + for _, attr := range attrs { + if attr.GetKey() == "peer.service" { + span.Kind = tracepb.Span_SPAN_KIND_CLIENT + break + } + } + return span +} + +func assertErrorsContainPaymentFamily(t *testing.T, h *ingestv2.ReadHandler) { + t.Helper() + rw := httpGET(t, h.Errors, "/v1/errors?window=15m&limit=10") if rw.Code != http.StatusOK { - t.Fatalf("recent traces: expected 200, got %d", rw.Code) + t.Fatalf("errors: expected 200, got %d body=%s", rw.Code, rw.Body.String()) } - var recentResp struct { - Traces []struct { - TraceID string `json:"trace_id"` - Service string `json:"service"` - } `json:"traces"` + var resp apiv2.ErrorsResponse + decodeJSON(t, rw, &resp) + for _, row := range resp.Rows { + if row.ErrorFamily.Service == "checkout" && row.ErrorFamily.Step == "payment.charge" && row.ErrorFamily.ErrorCode == "PMT_502" { + if row.Count != 1 || row.AffectedTraces != 1 { + t.Fatalf("payment row counts=%+v", row) + } + return + } } - decodeJSON(t, rw, &recentResp) - wantTraceID := "aabbccddeeff00112233445566778899" - found := false - for _, tr := range recentResp.Traces { - if tr.TraceID == wantTraceID { - found = true + t.Fatalf("payment family not found in rows: %+v", resp.Rows) +} + +func assertStoryShowsPaymentFailure(t *testing.T, h *ingestv2.ReadHandler, traceID string) { + t.Helper() + rw := httpGET(t, h.TraceStory, "/v1/traces/story?trace_id="+traceID) + if rw.Code != http.StatusOK { + t.Fatalf("trace story: expected 200, got %d body=%s", rw.Code, rw.Body.String()) + } + var story apiv2.StoryResponse + decodeJSON(t, rw, &story) + if story.Status != eventv2.StatusError { + t.Fatalf("status=%q want error", story.Status) + } + if story.Anchor == nil || story.Anchor.Step != "payment.charge" || story.Anchor.ErrorCode != "PMT_502" { + t.Fatalf("anchor=%+v", story.Anchor) + } + if story.Linkage != apiv2.LinkageCausal { + t.Fatalf("linkage=%q want causal", story.Linkage) + } + foundDownstream := false + for _, downstream := range story.Downstream { + if downstream.Step == "payment.charge" && downstream.Service == "payment" { + foundDownstream = true break } } - if !found { - t.Errorf("expected trace %s in recent traces, got %+v", wantTraceID, recentResp.Traces) + if !foundDownstream { + t.Fatalf("downstream payment not found: %+v", story.Downstream) } +} - // Verify /v1/capabilities advertises OTLP enabled. +func assertBlastShowsPaymentImpact(t *testing.T, h *ingestv2.ReadHandler) { + t.Helper() + rw := httpGET(t, h.BlastRadius, "/v1/blast_radius?service=checkout&step=payment.charge&error_code=PMT_502&window=15m") + if rw.Code != http.StatusOK { + t.Fatalf("blast: expected 200, got %d body=%s", rw.Code, rw.Body.String()) + } + var blast apiv2.BlastRadiusResponse + decodeJSON(t, rw, &blast) + if blast.AffectedRequests != 1 || blast.ViewMode != apiv2.BlastViewSingleFamily { + t.Fatalf("blast=%+v", blast) + } +} + +func assertCapabilitiesAdvertiseOTLPAndV2Reads(t *testing.T, srv *ingest.Server) { + t.Helper() cw := httpGET(t, srv.Capabilities, "/v1/capabilities") if cw.Code != http.StatusOK { t.Fatalf("capabilities: expected 200, got %d", cw.Code) } - var caps map[string]any + var caps struct { + OTLP struct { + HTTPTraces bool `json:"http_traces"` + } `json:"otlp"` + V2Reads struct { + Enabled bool `json:"enabled"` + } `json:"v2_reads"` + } decodeJSON(t, cw, &caps) - otlp, ok := caps["otlp"].(map[string]any) - if !ok { - t.Fatalf("capabilities missing otlp section: %v", caps) + if !caps.OTLP.HTTPTraces { + t.Fatal("expected otlp.http_traces=true") } - if enabled, _ := otlp["http_traces"].(bool); !enabled { - t.Errorf("expected otlp.http_traces=true, got %v", otlp["http_traces"]) + if !caps.V2Reads.Enabled { + t.Fatal("expected v2_reads.enabled=true") } } -// silence unused-import for json when no other test in the file uses it -// (decodeJSON in helpers_test.go re-exports usage, but keep this file -// self-evident for future readers). -var _ = json.Marshal +type fakeV2WAL struct { + mu sync.Mutex + writes [][]byte +} + +func (w *fakeV2WAL) WriteRaw(line []byte) error { + w.mu.Lock() + defer w.mu.Unlock() + w.writes = append(w.writes, append([]byte(nil), line...)) + return nil +} From fc55828cd9aea1f60be0ae5a9c08df7e570ad48f Mon Sep 17 00:00:00 2001 From: skota-hash Date: Mon, 4 May 2026 04:15:15 -0400 Subject: [PATCH 09/12] feat: finished v2 operator CLI read verbs Completed the operator CLI as the terminal entrypoint for the v2 read APIs. - added `waylog capabilities` so operators can inspect server support without requiring v2-read gating - added `waylog recent` over `/v1/traces/recent` with window, service, status, limit, cursor, and include-suppressed filters - add `waylog event ` over `/v1/events/` - add client methods and response/param types for recent traces, event lookup, and expanded capability output - add human renderers for capabilities, recent trace summaries, and single event summaries - keep JSON output behavior consistent with existing verbs - update CLI help with the recommended triage loop: recent -> errors -> blast -> explain - update README CLI examples to include capabilities, recent, event, and display-family blast usage - add command and renderer tests for new verbs, query serialization, v2-read gating, ungated capabilities, readable output, and cursor rendering Verification: - GOCACHE=/tmp/go-build go test ./internal/cli/v2 - GOCACHE=/tmp/go-build go test ./cmd/waylog ./internal/cli/v2 - GOCACHE=/tmp/go-build go test ./... - GOCACHE=/tmp/go-build go vet ./... - git diff --check --- README.md | 7 ++- internal/cli/v2/client.go | 21 +++++++++ internal/cli/v2/cmd.go | 52 ++++++++++++++++++++ internal/cli/v2/cmd_test.go | 74 +++++++++++++++++++++++++++++ internal/cli/v2/render.go | 86 ++++++++++++++++++++++++++++++++++ internal/cli/v2/render_test.go | 58 +++++++++++++++++++++++ internal/cli/v2/types.go | 16 +++++++ 7 files changed, 312 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c36b66b..6846e73 100644 --- a/README.md +++ b/README.md @@ -150,14 +150,17 @@ Runs only the ingest server. Point your own services at it via an SDK or OTLP. F ```bash WAYLOG_V2_READS=true ./ingest +waylog capabilities +waylog recent --limit 5 waylog errors --window 15m +waylog blast checkout:payment.charge:PMT_502 --window 15m waylog explain trace_01HX... waylog trace trace_01HX... -waylog blast --service checkout --step payment.charge --code PMT_502 +waylog event event_01HX... waylog search PMT_502 --window 1h ``` -The `waylog` binary is now the v2 operator CLI over the running ingest server's read APIs. It requires the server to advertise `v2_reads.enabled=true` from `/v1/capabilities` and uses `INGEST_ADDR`, `WAYLOG_READ_KEY`, and `WAYLOG_CLI_TIMEOUT` by default. Add `--json` to any verb for machine-readable output. +The `waylog` binary is now the v2 operator CLI over the running ingest server's read APIs. Most verbs require the server to advertise `v2_reads.enabled=true` from `/v1/capabilities`; `waylog capabilities` is intentionally ungated so it can diagnose server setup. The CLI uses `INGEST_ADDR`, `WAYLOG_READ_KEY`, and `WAYLOG_CLI_TIMEOUT` by default. Add `--json` to any verb for machine-readable output. ### REST (direct tool call) diff --git a/internal/cli/v2/client.go b/internal/cli/v2/client.go index 6c7dab9..add4f62 100644 --- a/internal/cli/v2/client.go +++ b/internal/cli/v2/client.go @@ -97,6 +97,27 @@ func (c *Client) Errors(ctx context.Context, p ErrorsParams) (ErrorsResponse, er return out, err } +func (c *Client) Recent(ctx context.Context, p RecentParams) (RecentTracesResponse, error) { + q := url.Values{} + addQuery(q, "window", p.Window) + addQuery(q, "service", p.Service) + addQuery(q, "status", p.Status) + addQuery(q, "cursor", p.Cursor) + addLimit(q, p.Limit) + if p.IncludeSuppressed { + q.Set("include_suppressed", "true") + } + var out RecentTracesResponse + err := c.do(ctx, "/v1/traces/recent", q, &out) + return out, err +} + +func (c *Client) Event(ctx context.Context, eventID string) (*Event, error) { + var out Event + err := c.do(ctx, "/v1/events/"+url.PathEscape(eventID), nil, &out) + return &out, err +} + func (c *Client) Trace(ctx context.Context, traceID string) (TraceGetResponse, error) { var out TraceGetResponse err := c.do(ctx, "/v1/traces/"+url.PathEscape(traceID), nil, &out) diff --git a/internal/cli/v2/cmd.go b/internal/cli/v2/cmd.go index 661b362..f0256a9 100644 --- a/internal/cli/v2/cmd.go +++ b/internal/cli/v2/cmd.go @@ -44,8 +44,14 @@ func RunCLI(args []string, _ io.Reader, stdout, stderr io.Writer) int { ctx := context.Background() switch rest[0] { + case "capabilities": + return runCapabilities(ctx, client, cfg, rest[1:], stdout, stderr) + case "recent": + return runRecent(ctx, client, cfg, rest[1:], stdout, stderr) case "errors": return runErrors(ctx, client, cfg, rest[1:], stdout, stderr) + case "event": + return runEvent(ctx, client, cfg, rest[1:], stdout, stderr) case "trace": return runTrace(ctx, client, cfg, rest[1:], stdout, stderr) case "explain": @@ -133,6 +139,14 @@ func requireV2Reads(ctx context.Context, client *Client, stderr io.Writer) int { return 0 } +func runCapabilities(ctx context.Context, client *Client, cfg cliConfig, args []string, stdout, stderr io.Writer) int { + if len(args) != 0 { + return usage(stderr, "usage: waylog capabilities [--json]") + } + resp, err := client.Capabilities(ctx) + return renderOrError(stdout, stderr, cfg.json, resp, err, RenderCapabilities) +} + func runErrors(ctx context.Context, client *Client, cfg cliConfig, args []string, stdout, stderr io.Writer) int { fs := newFlagSet("errors", stderr) window := fs.String("window", "", "") @@ -149,6 +163,35 @@ func runErrors(ctx context.Context, client *Client, cfg cliConfig, args []string return renderOrError(stdout, stderr, cfg.json, resp, err, RenderErrors) } +func runRecent(ctx context.Context, client *Client, cfg cliConfig, args []string, stdout, stderr io.Writer) int { + fs := newFlagSet("recent", stderr) + window := fs.String("window", "", "") + service := fs.String("service", "", "") + status := fs.String("status", "", "") + limit := fs.Int("limit", 0, "") + cursor := fs.String("cursor", "", "") + includeSuppressed := fs.Bool("include-suppressed", false, "") + if err := fs.Parse(args); err != nil || fs.NArg() != 0 { + return usage(stderr, "usage: waylog recent [--window ] [--service ] [--status ] [--limit ] [--cursor ] [--include-suppressed] [--json]") + } + if gate := requireV2Reads(ctx, client, stderr); gate != 0 { + return gate + } + resp, err := client.Recent(ctx, RecentParams{Window: *window, Service: *service, Status: *status, Limit: *limit, Cursor: *cursor, IncludeSuppressed: *includeSuppressed}) + return renderOrError(stdout, stderr, cfg.json, resp, err, RenderRecent) +} + +func runEvent(ctx context.Context, client *Client, cfg cliConfig, args []string, stdout, stderr io.Writer) int { + if len(args) != 1 { + return usage(stderr, "usage: waylog event [--json]") + } + if gate := requireV2Reads(ctx, client, stderr); gate != 0 { + return gate + } + resp, err := client.Event(ctx, args[0]) + return renderOrError(stdout, stderr, cfg.json, resp, err, RenderEvent) +} + func runTrace(ctx context.Context, client *Client, cfg cliConfig, args []string, stdout, stderr io.Writer) int { if len(args) != 1 { return usage(stderr, "usage: waylog trace [--json]") @@ -282,12 +325,21 @@ func usage(stderr io.Writer, msg string) int { func printUsage(w io.Writer) { fmt.Fprintln(w, `Usage: + waylog capabilities [--json] + waylog recent [--window ] [--service ] [--status ] [--limit ] [--cursor ] [--include-suppressed] [--json] waylog errors [--window ] [--service ] [--limit ] [--cursor ] [--json] + waylog event [--json] waylog trace [--json] waylog explain [--json] waylog blast (--service --step --code | --code | ) [--window ] [--json] waylog search [--service ] [--status ] [--window ] [--limit ] [--cursor ] [--json] +Recommended loop: + waylog recent + waylog errors --window 15m + waylog blast checkout:payment.charge:PMT_502 --window 15m + waylog explain + Global flags: --addr ingest base URL (default INGEST_ADDR or http://localhost:8080) --api-key read API key (default WAYLOG_READ_KEY) diff --git a/internal/cli/v2/cmd_test.go b/internal/cli/v2/cmd_test.go index 33589b9..4d7a449 100644 --- a/internal/cli/v2/cmd_test.go +++ b/internal/cli/v2/cmd_test.go @@ -48,6 +48,80 @@ func TestRunCLIErrorsHappyPath(t *testing.T) { } } +func TestRunCLIRecentSerializesFilters(t *testing.T) { + var gotPath, gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/capabilities" { + _, _ = w.Write([]byte(`{"v2_reads":{"enabled":true}}`)) + return + } + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + _, _ = w.Write([]byte(`{"traces":[],"next_cursor":null}`)) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := RunCLI([]string{"--addr", srv.URL, "recent", "--window", "15m", "--service", "checkout", "--status", "error,timeout", "--limit", "5", "--cursor", "abc", "--include-suppressed"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + for _, want := range []string{"window=15m", "service=checkout", "status=error%2Ctimeout", "limit=5", "cursor=abc", "include_suppressed=true"} { + if !strings.Contains(gotQuery, want) { + t.Fatalf("query=%q missing %q", gotQuery, want) + } + } + if gotPath != "/v1/traces/recent" { + t.Fatalf("path=%q", gotPath) + } +} + +func TestRunCLIEventEscapesIDAndRequiresV2Reads(t *testing.T) { + calls := []string{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.URL.String()) + if r.URL.Path == "/v1/capabilities" { + _, _ = w.Write([]byte(`{"v2_reads":{"enabled":true}}`)) + return + } + _, _ = w.Write([]byte(`{"event_id":"event/1","trace_id":"trace","service":"checkout","status":"ok","duration_ms":3}`)) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := RunCLI([]string{"--addr", srv.URL, "event", "event/1"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if len(calls) != 2 || calls[1] != "/v1/events/event%2F1" { + t.Fatalf("calls=%v", calls) + } + if !strings.Contains(stdout.String(), "event_id: event/1") { + t.Fatalf("stdout=%q", stdout.String()) + } +} + +func TestRunCLICapabilitiesDoesNotRequireV2Reads(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte(`{"v2_reads":{"enabled":false},"otlp":{"http_traces":true}}`)) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := RunCLI([]string{"--addr", srv.URL, "capabilities"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if gotPath != "/v1/capabilities" { + t.Fatalf("path=%q", gotPath) + } + if !strings.Contains(stdout.String(), "v2_reads: disabled") || !strings.Contains(stdout.String(), "otlp_http_traces: enabled") { + t.Fatalf("stdout=%q", stdout.String()) + } +} + func TestRunCLIExplainFallsBackToTraceID(t *testing.T) { calls := []string{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/cli/v2/render.go b/internal/cli/v2/render.go index 5b831dc..e4051cc 100644 --- a/internal/cli/v2/render.go +++ b/internal/cli/v2/render.go @@ -42,6 +42,55 @@ func RenderErrors(w io.Writer, resp ErrorsResponse) { renderNextCursor(w, resp.NextCursor) } +func RenderRecent(w io.Writer, resp RecentTracesResponse) { + if len(resp.Traces) == 0 { + fmt.Fprintln(w, "No recent traces found.") + renderNextCursor(w, resp.NextCursor) + return + } + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "TRACE\tSTATUS\tSERVICES\tDURATION\tANCHOR\tSTART") + for _, trace := range resp.Traces { + anchor := "" + if trace.AnchorSummary != nil { + anchor = *trace.AnchorSummary + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%d ms\t%s\t%s\n", + truncateID(trace.TraceID), + trace.Status, + strings.Join(trace.Services, " -> "), + trace.DurationMS, + anchor, + formatTime(trace.TsStart), + ) + } + _ = tw.Flush() + renderNextCursor(w, resp.NextCursor) +} + +func RenderEvent(w io.Writer, ev *Event) { + if ev == nil { + fmt.Fprintln(w, "No event found.") + return + } + fmt.Fprintf(w, "event_id: %s\n", ev.EventID) + fmt.Fprintf(w, "trace_id: %s\n", ev.TraceID) + fmt.Fprintf(w, "service: %s\n", ev.Service) + fmt.Fprintf(w, "status: %s\n", ev.Status) + fmt.Fprintf(w, "duration_ms: %d\n", ev.DurationMS) + if route := eventRoute(ev); route != "" { + fmt.Fprintf(w, "route: %s\n", route) + } + if ev.Anchor == nil { + fmt.Fprintln(w, "anchor: none") + } else { + fmt.Fprintf(w, "anchor: %s -> %s\n", ev.Anchor.Step, ev.Anchor.ErrorCode) + } + fmt.Fprintf(w, "steps: %d\n", len(ev.Steps)) + fmt.Fprintf(w, "logs: %d\n", len(ev.Logs)) + fmt.Fprintf(w, "downstream: %d\n", countEventDownstream(ev)) +} + func RenderTrace(w io.Writer, resp TraceGetResponse) { fmt.Fprintf(w, "trace_id: %s\n", resp.TraceID) fmt.Fprintf(w, "linkage: %s\n\n", resp.Linkage) @@ -138,6 +187,43 @@ func RenderSearch(w io.Writer, resp EventSearchResponse) { renderNextCursor(w, resp.NextCursor) } +func RenderCapabilities(w io.Writer, resp CapabilitiesResponse) { + fmt.Fprintf(w, "v2_reads: %s\n", enabledLabel(resp.V2Reads.Enabled)) + fmt.Fprintf(w, "otlp_http_traces: %s\n", enabledLabel(resp.OTLP.HTTPTraces)) +} + +func eventRoute(ev *Event) string { + if ev == nil || ev.Fields == nil { + return "" + } + httpFields, ok := ev.Fields["http"].(map[string]any) + if !ok { + return "" + } + route, _ := httpFields["route"].(string) + return route +} + +func countEventDownstream(ev *Event) int { + if ev == nil { + return 0 + } + total := 0 + for _, step := range ev.Steps { + if step.Downstream != nil { + total++ + } + } + return total +} + +func enabledLabel(enabled bool) string { + if enabled { + return "enabled" + } + return "disabled" +} + func renderNextCursor(w io.Writer, cursor *string) { if cursor != nil && *cursor != "" { fmt.Fprintf(w, "\nnext_cursor: %s\n", *cursor) diff --git a/internal/cli/v2/render_test.go b/internal/cli/v2/render_test.go index d0dbb2c..598c54c 100644 --- a/internal/cli/v2/render_test.go +++ b/internal/cli/v2/render_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "strings" "testing" + "time" apiv2 "github.com/sssmaran/WaylogCLI/pkg/api/v2" eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" @@ -36,6 +37,63 @@ func TestRenderBlastPinsViewMode(t *testing.T) { } } +func TestRenderRecentPrintsTraceSummaryAndCursor(t *testing.T) { + next := "next" + anchor := "payment.charge/PMT_502" + var out bytes.Buffer + RenderRecent(&out, RecentTracesResponse{ + Traces: []TraceSummary{{ + TraceID: "trace-1234567890", + Status: eventv2.StatusError, + Services: []string{"api-gateway", "checkout", "payment"}, + DurationMS: 42, + AnchorSummary: &anchor, + TsStart: time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC), + }}, + NextCursor: &next, + }) + got := out.String() + for _, want := range []string{"TRACE", "error", "api-gateway -> checkout -> payment", "payment.charge/PMT_502", "next_cursor: next"} { + if !strings.Contains(got, want) { + t.Fatalf("output missing %q:\n%s", want, got) + } + } +} + +func TestRenderEventPrintsSummaryCounts(t *testing.T) { + var out bytes.Buffer + RenderEvent(&out, &Event{ + EventID: "event", + TraceID: "trace", + Service: "checkout", + Status: eventv2.StatusError, + DurationMS: 12, + Anchor: &eventv2.Anchor{Step: "payment.charge", ErrorCode: "PMT_502"}, + Fields: map[string]any{"http": map[string]any{"route": "/checkout"}}, + Steps: []eventv2.Step{ + {Name: "db.load_cart", Status: eventv2.StepStatusOK}, + {Name: "payment.charge", Status: eventv2.StepStatusError, Downstream: &eventv2.Downstream{Service: "payment"}}, + }, + Logs: []eventv2.Log{{Level: eventv2.LogLevelError, Msg: "upstream gateway 5xx"}}, + }) + got := out.String() + for _, want := range []string{"event_id: event", "trace_id: trace", "route: /checkout", "anchor: payment.charge -> PMT_502", "steps: 2", "logs: 1", "downstream: 1"} { + if !strings.Contains(got, want) { + t.Fatalf("output missing %q:\n%s", want, got) + } + } +} + +func TestRenderCapabilitiesPrintsReadableFlags(t *testing.T) { + var out bytes.Buffer + resp := CapabilitiesResponse{} + resp.OTLP.HTTPTraces = true + RenderCapabilities(&out, resp) + if !strings.Contains(out.String(), "v2_reads: disabled") || !strings.Contains(out.String(), "otlp_http_traces: enabled") { + t.Fatalf("output=%s", out.String()) + } +} + func TestRenderJSONPrettyPrints(t *testing.T) { var out bytes.Buffer if err := renderJSON(&out, ErrorsResponse{Rows: []ErrorRow{}}); err != nil { diff --git a/internal/cli/v2/types.go b/internal/cli/v2/types.go index 916b702..1864efb 100644 --- a/internal/cli/v2/types.go +++ b/internal/cli/v2/types.go @@ -4,15 +4,22 @@ import ( "time" apiv2 "github.com/sssmaran/WaylogCLI/pkg/api/v2" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" ) type CapabilitiesResponse struct { V2Reads struct { Enabled bool `json:"enabled"` } `json:"v2_reads"` + OTLP struct { + HTTPTraces bool `json:"http_traces"` + } `json:"otlp"` } type EventSearchResponse = apiv2.EventSearchResponse +type Event = eventv2.Event +type RecentTracesResponse = apiv2.RecentTracesResponse +type TraceSummary = apiv2.TraceSummary type TraceGetResponse = apiv2.TraceGetResponse type StoryResponse = apiv2.StoryResponse type StoryAnchor = apiv2.StoryAnchor @@ -32,6 +39,15 @@ type ErrorsParams struct { Cursor string } +type RecentParams struct { + Window string + Service string + Status string + Limit int + Cursor string + IncludeSuppressed bool +} + type StoryQuery struct { EventID string TraceID string From 298ec0bee45bb497b6d1b5279f7c4e9da9b38734 Mon Sep 17 00:00:00 2001 From: skota-hash Date: Mon, 4 May 2026 04:17:45 -0400 Subject: [PATCH 10/12] fix: decoded v2 event lookup envelope in CLI Fixed `waylog event ` to match the actual v2 read API response shape. - decoded `/v1/events/{id}` as `{"event": {...}}` instead of a raw event body - added a small internal response wrapper for event lookup - update the CLI event command test to assert the real server envelope shape --- internal/cli/v2/client.go | 4 ++-- internal/cli/v2/cmd_test.go | 2 +- internal/cli/v2/types.go | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/cli/v2/client.go b/internal/cli/v2/client.go index add4f62..0ce7100 100644 --- a/internal/cli/v2/client.go +++ b/internal/cli/v2/client.go @@ -113,9 +113,9 @@ func (c *Client) Recent(ctx context.Context, p RecentParams) (RecentTracesRespon } func (c *Client) Event(ctx context.Context, eventID string) (*Event, error) { - var out Event + var out eventGetResponse err := c.do(ctx, "/v1/events/"+url.PathEscape(eventID), nil, &out) - return &out, err + return out.Event, err } func (c *Client) Trace(ctx context.Context, traceID string) (TraceGetResponse, error) { diff --git a/internal/cli/v2/cmd_test.go b/internal/cli/v2/cmd_test.go index 4d7a449..e813010 100644 --- a/internal/cli/v2/cmd_test.go +++ b/internal/cli/v2/cmd_test.go @@ -84,7 +84,7 @@ func TestRunCLIEventEscapesIDAndRequiresV2Reads(t *testing.T) { _, _ = w.Write([]byte(`{"v2_reads":{"enabled":true}}`)) return } - _, _ = w.Write([]byte(`{"event_id":"event/1","trace_id":"trace","service":"checkout","status":"ok","duration_ms":3}`)) + _, _ = w.Write([]byte(`{"event":{"event_id":"event/1","trace_id":"trace","service":"checkout","status":"ok","duration_ms":3}}`)) })) defer srv.Close() diff --git a/internal/cli/v2/types.go b/internal/cli/v2/types.go index 1864efb..6672d99 100644 --- a/internal/cli/v2/types.go +++ b/internal/cli/v2/types.go @@ -32,6 +32,10 @@ type ErrorsResponse = apiv2.ErrorsResponse type BlastKey = apiv2.BlastKey type BlastRadiusResponse = apiv2.BlastRadiusResponse +type eventGetResponse struct { + Event *Event `json:"event"` +} + type ErrorsParams struct { Window string Service string From 9c98a5d4ff967c7618d593f7085b5c93395214af Mon Sep 17 00:00:00 2001 From: skota-hash Date: Mon, 4 May 2026 05:22:06 -0400 Subject: [PATCH 11/12] docs: aligned OpenAPI with v2 read API surface and removed stale demo scripts --- README.md | 4 +- docs/openapi.yaml | 1708 +++++++++++++++++++------------ examples/microdemo/gateway.go | 3 - examples/microdemo/scenario.go | 11 - scripts/demo-agent-triage.sh | 151 --- scripts/demo-cascade-failure.sh | 190 ---- scripts/demo-comparison.sh | 115 --- scripts/demo-deploy-failure.sh | 125 --- 8 files changed, 1043 insertions(+), 1264 deletions(-) delete mode 100755 scripts/demo-agent-triage.sh delete mode 100755 scripts/demo-cascade-failure.sh delete mode 100755 scripts/demo-comparison.sh delete mode 100755 scripts/demo-deploy-failure.sh diff --git a/README.md b/README.md index 6846e73..6fbd917 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,6 @@ Stop with `make demo-stop`. Prefer Docker? Use `make docker-dev` / `make docker-down`. Prefer foreground service logs while hacking on Go code? Use `make micro-demo` and stop with `make micro-demo-stop`. -> `./scripts/demo-cascade-failure.sh` injects an equivalent fixture by POSTing synthetic events directly. It is a fixture, not a substitute for the live path above. - ## How it works @@ -255,7 +253,7 @@ Go / TS services (SDK) · OTLP/HTTP collectors Events are durably logged before projection — if the process crashes, replay rebuilds the read models from the WAL on next boot. -Durability model, retention, merge semantics, readiness policy, and counter buffer: [`docs/internals.md`](docs/internals.md). Full HTTP contract: [`docs/openapi.yaml`](docs/openapi.yaml). +Durability model, retention, merge semantics, readiness policy, and counter buffer: [`docs/internals.md`](docs/internals.md). Full v2 HTTP contract: [`docs/openapi.yaml`](docs/openapi.yaml). ## Development diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f12d4a4..9d874ee 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,34 +1,48 @@ openapi: 3.0.3 info: title: Waylog API + version: 0.2.0 description: | HTTP API for the Waylog ingest server. - Write endpoints require authentication via API key. - Read endpoints are open (protected by CORS only). - - ## Metrics Contract v2 - - **Breaking changes from v1:** - - - `waylog_dedup_cache_hits_total` renamed to `waylog_dedup_replay_total` - (now counts both cache-hit replays and inflight-wait replays). + The primary product path is schema-2.0 ingest plus the v2 read APIs used by + the operator CLI and embedded dashboard: recent traces, error families, + trace story, blast radius, event search, and direct trace/event lookup. - **`waylog_ask_requests_total` error_code label bounded set:** - `INVALID_PARAMS`, `NOT_FOUND`, `EMPTY_RESULT`, `TIMEOUT`, `INTERNAL`, - `GRAPH_EMPTY`, `PROVIDER_ERROR`, `PAYLOAD_TOO_LARGE`, `CONFLICT`, - `SERVICE_UNAVAILABLE`, `METHOD_NOT_ALLOWED`, `UNAUTHORIZED`, `none`. - Any unlisted error code is normalized to `INTERNAL`. - version: 0.2.0 + Write endpoints require write-scope auth when auth is configured. Read + endpoints require read-scope auth when read keys are configured. servers: - url: / + +tags: + - name: Ingest + description: Schema-2.0 event ingest and validation. + - name: OTLP + description: OTLP/HTTP trace ingest converted into schema-2.0 events. + - name: Events + description: Direct event lookup and search. + - name: Traces + description: Trace lookup, recent trace summaries, and trace stories. + - name: Triage + description: Error-family rollups and blast-radius analysis. + - name: Capabilities + description: Runtime capability discovery for CLI and UI clients. + - name: Operational + description: Secondary operational graph, tool, and agent endpoints. + - name: Health + description: Process health, readiness, and metrics endpoints. + paths: /v1/events: post: - summary: Ingest a WideEvent + tags: [Ingest] + operationId: ingestEvents + summary: Ingest schema-2.0 WideEvents description: | - Accepts a WideEvent JSON payload, validates it against the schema, - and ingests it into the graph. + Accepts one schema-2.0 WideEvent as JSON or a batch as NDJSON. Accepted + events are written to the schema-2.0 WAL before projection into the v2 + read models. Per-event schema failures are returned inside the ingest + envelope; infrastructure failures return 503. security: - ApiKeyHeader: [] - BearerAuth: [] @@ -38,574 +52,452 @@ paths: application/json: schema: $ref: '#/components/schemas/WideEvent' + application/x-ndjson: + schema: + type: string + description: Newline-delimited schema-2.0 WideEvent JSON objects. responses: - '202': - description: Accepted - '400': - description: Invalid request (validation failure) + '200': + description: Ingest envelope content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/Problem' - '401': - description: Unauthorized (missing or invalid API key) + $ref: '#/components/schemas/IngestEnvelope' + '400': + description: Malformed body, missing content type, unsupported encoding, or invalid cursor/body framing content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/Problem' + $ref: '#/components/schemas/IngestEnvelope' + '401': + description: Unauthorized '405': description: Method Not Allowed '413': - description: Request Entity Too Large + description: Body or batch too large + content: + application/json: + schema: + $ref: '#/components/schemas/IngestEnvelope' + '415': + description: Unsupported content type + content: + application/json: + schema: + $ref: '#/components/schemas/IngestEnvelope' '503': - description: Event log unavailable + description: Durability or projection unavailable /v1/events/validate: post: - summary: Dry-run event validation + tags: [Ingest] + operationId: validateEvents + summary: Dry-run schema-2.0 event validation description: | - Validates a WideEvent payload without ingesting it. - Returns {valid: true} on success or {valid: false, errors: [...]} on failure. + Parses and validates JSON or NDJSON with the same contract as + /v1/events, but does not dedupe, write to WAL, or project events. + security: + - ApiKeyHeader: [] + - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/WideEvent' + application/x-ndjson: + schema: + type: string responses: '200': - description: Valid event + description: Dry-run ingest envelope content: application/json: schema: - type: object - properties: - valid: - type: boolean - example: true + $ref: '#/components/schemas/IngestEnvelope' '400': - description: Invalid event + description: Malformed body or unsupported encoding content: application/json: schema: - type: object - properties: - valid: - type: boolean - example: false - errors: - type: array - items: - type: string + $ref: '#/components/schemas/IngestEnvelope' '405': description: Method Not Allowed '413': - description: Request Entity Too Large + description: Body or batch too large + content: + application/json: + schema: + $ref: '#/components/schemas/IngestEnvelope' + '415': + description: Unsupported content type + content: + application/json: + schema: + $ref: '#/components/schemas/IngestEnvelope' + + /v1/otlp/v1/traces: + post: + tags: [OTLP] + operationId: ingestOTLPTraces + summary: Ingest OTLP/HTTP traces + description: | + Accepts OTLP ExportTraceServiceRequest protobuf bodies. HTTP spans are + converted into schema-2.0 WideEvents and ingested through the same WAL + and projector as SDK events. Unsupported spans and validation rejects + are reported via OTLP partial_success. + security: + - ApiKeyHeader: [] + - BearerAuth: [] + requestBody: + required: true + content: + application/x-protobuf: + schema: + type: string + format: binary + responses: + '200': + description: OTLP ExportTraceServiceResponse protobuf + content: + application/x-protobuf: + schema: + type: string + format: binary + '400': + description: Invalid gzip body, unreadable body, or invalid protobuf + '401': + description: Unauthorized + '405': + description: Method Not Allowed + '413': + description: Request body too large + '415': + description: Unsupported content type or content encoding + '503': + description: v2 ingest infrastructure unavailable + + /v1/events/{event_id}: + get: + tags: [Events] + operationId: getEvent + summary: Get one schema-2.0 event + security: + - ApiKeyHeader: [] + - BearerAuth: [] + parameters: + - $ref: '#/components/parameters/EventID' + responses: + '200': + description: Event wrapper + content: + application/json: + schema: + $ref: '#/components/schemas/EventGetResponse' + '400': + $ref: '#/components/responses/ReadBadRequest' + '404': + $ref: '#/components/responses/ReadNotFound' /v1/events/search: get: - summary: Search raw event log - description: | - Searches the append-only event log by filters. - At least one filter is required. Uses SQLite cold store when SQLITE_PATH - is configured, with JSONL event log fallback. + tags: [Events] + operationId: searchEvents + summary: Search schema-2.0 events + description: Searches the v2 recent index with cursor pagination. + security: + - ApiKeyHeader: [] + - BearerAuth: [] parameters: - - in: query - name: trace_id - schema: - type: string - - in: query - name: user_id - schema: - type: string - - in: query - name: service - schema: - type: string - - in: query - name: error_code - schema: - type: string - - in: query - name: start - schema: - type: string - format: date-time - description: RFC3339 start time filter - - in: query - name: end - schema: - type: string - format: date-time - description: RFC3339 end time filter - - in: query - name: limit - schema: - type: integer - default: 50 - maximum: 200 - - in: query - name: cursor - schema: - type: string - description: Opaque cursor for keyset pagination (from previous response next_cursor) + - $ref: '#/components/parameters/Service' + - $ref: '#/components/parameters/StatusCSV' + - $ref: '#/components/parameters/ErrorCode' + - $ref: '#/components/parameters/TraceIDQuery' + - $ref: '#/components/parameters/Since' + - $ref: '#/components/parameters/Until' + - $ref: '#/components/parameters/Window' + - $ref: '#/components/parameters/IncludeSuppressed' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' responses: '200': - description: Search results + description: Event search results content: application/json: schema: - type: object - properties: - events: - type: array - items: - $ref: '#/components/schemas/WideEvent' - count: - type: integer - total_count: - type: integer - description: Total matching events (across all pages) - next_cursor: - type: string - description: Cursor for next page (absent on last page) - data_source: - type: string - enum: [sqlite, event_log_fallback] + $ref: '#/components/schemas/EventSearchResponse' '400': - description: No filters provided or invalid time format - '503': - description: Event log not configured + $ref: '#/components/responses/ReadBadRequest' - /v1/traces/story: + /v1/traces/{trace_id}: get: - summary: Trace story narrative - description: | - Returns a human-readable story narrative for the given trace, - built from the graph's span and service data. + tags: [Traces] + operationId: getTraceEvents + summary: Get all events for a trace + security: + - ApiKeyHeader: [] + - BearerAuth: [] parameters: - - in: query - name: trace_id - required: true - schema: - type: string - description: The trace ID to build a story for + - $ref: '#/components/parameters/TraceIDPath' responses: '200': - description: Trace story + description: Trace events content: application/json: schema: - type: object - properties: - story: - type: object - properties: - trace_id: - type: string - chain: - type: array - items: - type: object - success: - type: boolean - first_fail_hop: - type: object - nullable: true - hop_count: - type: integer - context: - type: object + $ref: '#/components/schemas/TraceGetResponse' '400': - description: Missing trace_id query parameter + $ref: '#/components/responses/ReadBadRequest' '404': - description: Trace not found - '503': - description: Service Unavailable (graph not ready) + $ref: '#/components/responses/ReadNotFound' /v1/traces/recent: get: + tags: [Traces] + operationId: listRecentTraces summary: Recent traces description: Returns recent trace summaries ordered by time descending with cursor pagination. + security: + - ApiKeyHeader: [] + - BearerAuth: [] parameters: - - in: query - name: limit - required: false - schema: - type: integer - default: 20 - minimum: 1 - maximum: 100 - description: Maximum number of traces to return (default 20, max 100) - - in: query - name: failures_only - required: false - schema: - type: boolean - default: false - description: When true, returns only failed requests. - - in: query - name: cursor - required: false - schema: - type: string - description: Opaque cursor for pagination (from previous response next_cursor) + - $ref: '#/components/parameters/Service' + - $ref: '#/components/parameters/StatusCSV' + - $ref: '#/components/parameters/Since' + - $ref: '#/components/parameters/Until' + - $ref: '#/components/parameters/Window' + - $ref: '#/components/parameters/IncludeSuppressed' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' responses: '200': - description: Paginated trace summaries + description: Recent trace summaries content: application/json: schema: - type: object - properties: - traces: - type: array - items: - type: object - properties: - trace_id: - type: string - service: - type: string - failure_service: - type: string - description: First failing span service for the request when available. - success: - type: boolean - status_code: - type: integer - latency_ms: - type: integer - event_name: - type: string - timestamp: - type: string - format: date-time - total_count: - type: integer - description: Total matching traces in graph - next_cursor: - type: string - description: Cursor for next page (absent on last page) - '503': - description: Service Unavailable (graph not ready) + $ref: '#/components/schemas/RecentTracesResponse' + '400': + $ref: '#/components/responses/ReadBadRequest' - /v1/overview: + /v1/traces/story: get: - summary: System overview - description: | - Returns an overview of the system including request counts, - error rates, top errors, and recent traces within the given window. + tags: [Traces] + operationId: getTraceStory + summary: Explain a trace as an execution story + description: Returns the service, route, first failing step, contributing path, logs, downstream calls, and linkage mode for one trace or event. + security: + - ApiKeyHeader: [] + - BearerAuth: [] parameters: - in: query - name: window + name: event_id schema: type: string - default: 5m - description: Time window as a Go duration string (e.g. "5m", "1h") + description: Event ID to explain. Exactly one of event_id or trace_id is required. - in: query - name: limit + name: trace_id schema: - type: integer - default: 20 - maximum: 100 - description: Max recent traces to include + type: string + description: Trace ID to explain. Exactly one of event_id or trace_id is required. responses: '200': - description: System overview stats + description: Trace story content: application/json: schema: - type: object - properties: - window: - type: string - total_requests: - type: integer - total_failures: - type: integer - error_rate: - type: number - p50: - type: integer - p95: - type: integer - p99: - type: integer - sampled: - type: boolean - top_errors: - type: array - items: - type: object - properties: - code: - type: string - count: - type: integer - recent_traces: - type: array - items: - type: object - '503': - description: Service Unavailable (graph not ready) + $ref: '#/components/schemas/StoryResponse' + '400': + $ref: '#/components/responses/ReadBadRequest' + '404': + $ref: '#/components/responses/ReadNotFound' - /v1/overview/timeseries: + /v1/errors: + get: + tags: [Triage] + operationId: listErrorFamilies + summary: Error family rollups + description: Groups failures by service, step, and error code. Suppressed events are excluded from error rollups. + security: + - ApiKeyHeader: [] + - BearerAuth: [] + parameters: + - $ref: '#/components/parameters/Service' + - $ref: '#/components/parameters/ErrorStatusCSV' + - $ref: '#/components/parameters/Since' + - $ref: '#/components/parameters/Until' + - $ref: '#/components/parameters/Window' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: Error family rows + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorsResponse' + '400': + $ref: '#/components/responses/ReadBadRequest' + + /v1/blast_radius: get: - summary: Overview timeseries + tags: [Triage] + operationId: getBlastRadius + summary: Blast radius for an error family or error code description: | - Returns bucketed timeseries data for request counts, error rates, - status class breakdown, and latency percentiles. + Computes affected requests, users, and services for one error family or + for all families with the same error code. + security: + - ApiKeyHeader: [] + - BearerAuth: [] parameters: + - $ref: '#/components/parameters/Service' - in: query - name: window + name: step schema: type: string - default: 1h - description: Time window (max 24h) + description: Step name. Use with service and error_code for single-family mode. + - $ref: '#/components/parameters/ErrorCode' - in: query - name: step + name: error_family schema: type: string - default: 5m - description: Bucket size (min 15s, max 15m). window/step must be <= 1440. + description: Display form service:step:error_code. + - $ref: '#/components/parameters/Since' + - $ref: '#/components/parameters/Until' + - $ref: '#/components/parameters/Window' responses: '200': - description: Timeseries buckets + description: Blast radius content: application/json: schema: - type: object - properties: - sampled: - type: boolean - buckets: - type: array - items: - type: object - properties: - start: - type: string - format: date-time - end: - type: string - format: date-time - total: - type: integer - failures: - type: integer - error_rate: - type: number - status_2xx: - type: integer - status_4xx: - type: integer - status_5xx: - type: integer - p50: - type: integer - p95: - type: integer - p99: - type: integer + $ref: '#/components/schemas/BlastRadiusResponse' '400': - description: Invalid parameters or guardrail violation + $ref: '#/components/responses/ReadBadRequest' + + /v1/capabilities: + get: + tags: [Capabilities] + operationId: getCapabilities + summary: Runtime capabilities + description: Returns runtime feature/configuration flags used by CLI and UI clients. + security: + - ApiKeyHeader: [] + - BearerAuth: [] + responses: + '200': + description: Capabilities + content: + application/json: + schema: + $ref: '#/components/schemas/CapabilitiesResponse' + '405': + description: Method Not Allowed + + /v1/overview: + get: + tags: [Operational] + operationId: getOperationalOverview + summary: Operational overview + description: Secondary graph-derived overview endpoint retained for operational tooling. + security: + - ApiKeyHeader: [] + - BearerAuth: [] + parameters: + - $ref: '#/components/parameters/Window' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: Overview object + content: + application/json: + schema: + type: object + additionalProperties: true '503': - description: Service Unavailable (graph not ready) + description: Graph store unavailable - /v1/routes: + /v1/overview/timeseries: get: - summary: Route statistics - description: | - Returns per-route statistics grouped by (service, event_name), - sorted by invocation count descending. + tags: [Operational] + operationId: getOperationalTimeseries + summary: Operational overview timeseries + description: Secondary graph-derived timeseries endpoint retained for operational tooling. + security: + - ApiKeyHeader: [] + - BearerAuth: [] parameters: + - $ref: '#/components/parameters/Window' - in: query - name: window + name: step schema: type: string default: 5m - description: Time window - - in: query - name: limit - schema: - type: integer - default: 20 - maximum: 100 - - in: query - name: failures_only - required: false - schema: - type: boolean - default: false - description: When true, includes only failed requests. responses: '200': - description: Route statistics + description: Timeseries buckets content: application/json: schema: type: object - properties: - sampled: - type: boolean - routes: - type: array - items: - type: object - properties: - service: - type: string - method: - type: string - description: HTTP method (e.g. GET, POST). UNKNOWN for legacy events. - route_template: - type: string - description: Route template (e.g. /users/{id}). Falls back to event_name for legacy events. - route: - type: string - description: "Deprecated: backward-compat alias for route_template." - deprecated: true - invocations: - type: integer - errors: - type: integer - error_rate: - type: number - status_2xx: - type: integer - status_4xx: - type: integer - status_5xx: - type: integer - p75_latency_ms: - type: integer - '503': - description: Service Unavailable (graph not ready) + additionalProperties: true + '400': + $ref: '#/components/responses/ReadBadRequest' - /v1/capabilities: + /v1/routes: get: - summary: Runtime capabilities - description: | - Returns runtime feature/configuration flags used by UI clients. + tags: [Operational] + operationId: listOperationalRoutes + summary: Operational route statistics + description: Secondary graph-derived route statistics endpoint retained for operational tooling. + security: + - ApiKeyHeader: [] + - BearerAuth: [] + parameters: + - $ref: '#/components/parameters/Window' + - $ref: '#/components/parameters/Limit' responses: '200': - description: Capabilities + description: Route statistics content: application/json: schema: type: object - properties: - ask: - type: object - properties: - enabled: - type: boolean - model: - type: string - tool_mode: - type: string - max_steps_default: - type: integer - max_steps_max: - type: integer - dashboard: - type: object - properties: - refresh_interval_sec: - type: integer - links: - type: object - properties: - prometheus: - type: string - description: Prometheus UI URL (empty if not configured) - grafana: - type: string - description: Grafana UI URL (empty if not configured) - graph: - type: boolean - description: Whether graph topology UI is enabled (GRAPH_UI=1) - '405': - description: Method Not Allowed + additionalProperties: true /v1/graph/topology: get: - summary: Service topology graph - description: | - Returns Cytoscape.js-formatted service topology with aggregate stats. - Only available when GRAPH_UI=1. + tags: [Operational] + operationId: getServiceTopology + summary: Optional service topology graph + description: Returns Cytoscape.js-formatted service topology when GRAPH_UI=1. + security: + - ApiKeyHeader: [] + - BearerAuth: [] parameters: - - in: query - name: window - schema: - type: string - default: 1h - description: Time window (max 24h) + - $ref: '#/components/parameters/Window' responses: '200': - description: Topology graph + description: Cytoscape topology graph content: application/json: schema: type: object - properties: - nodes: - type: array - items: - type: object - properties: - data: - type: object - properties: - id: - type: string - label: - type: string - type: - type: string - enum: [service] - invocations: - type: integer - description: Number of span invocations handled by this service - errors: - type: integer - error_rate: - type: number - edges: - type: array - items: - type: object - properties: - data: - type: object - properties: - source: - type: string - target: - type: string - label: - type: string - count: - type: integer + additionalProperties: true '405': description: Method Not Allowed '503': - description: Service Unavailable (graph not ready) + description: Graph store unavailable or GRAPH_UI disabled /v1/ask: post: - summary: Ask Waylog (LLM + tools) - description: | - Executes a natural-language query against the live graph using the - configured Gemini provider and built-in graph tools. - - **Idempotency:** Supports `Idempotency-Key` header for at-most-once - execution semantics. Scope is post-validation only — body parse errors - (400) and payload-too-large (413) are stateless and re-evaluated on - every request regardless of the key. Idempotency covers the execution - path: provider calls, tool calls, and result assembly. - x-idempotency-scope: post-validation + tags: [Operational] + operationId: askWaylog + summary: Ask Waylog + description: Executes a natural-language query through the configured LLM provider and tool registry. + security: + - ApiKeyHeader: [] + - BearerAuth: [] requestBody: required: true content: @@ -616,12 +508,10 @@ paths: properties: prompt: type: string - description: Natural-language question. max_steps: type: integer minimum: 1 maximum: 8 - description: Optional tool-call iteration cap. responses: '200': description: Query answered @@ -629,78 +519,31 @@ paths: application/json: schema: type: object - properties: - answer: - type: string - model: - type: string - tool_mode: - type: string - duration_ms: - type: integer - steps: - type: array - items: - type: object - properties: - index: - type: integer - tool: - type: string - duration_ms: - type: integer - params: - description: Tool arguments as structured JSON. - oneOf: - - type: object - additionalProperties: true - - type: array - items: {} - - type: string - - type: number - - type: boolean - result: - description: Tool output as structured JSON when available. - oneOf: - - type: object - additionalProperties: true - - type: array - items: {} - - type: string - - type: number - - type: boolean - error: - type: string + additionalProperties: true '400': - description: Invalid request body or missing prompt - '405': - description: Method Not Allowed + description: Invalid request '413': description: Request Entity Too Large '502': - description: Upstream/provider/tool execution failure + description: Provider or tool execution failure '503': - description: LLM provider not configured or store unavailable + description: LLM provider unavailable /v1/tools/{name}: post: + tags: [Operational] + operationId: executeTool summary: Direct tool call - description: | - Executes a registered graph tool by name with the given parameters. - Returns the tool's structured output directly. - - **Idempotency:** Supports `Idempotency-Key` header for at-most-once - execution semantics. Scope is post-validation only — body parse errors - (400) and payload-too-large (413) are stateless and re-evaluated on - every request regardless of the key. - x-idempotency-scope: post-validation + description: Executes a registered structured tool by name. + security: + - ApiKeyHeader: [] + - BearerAuth: [] parameters: - name: name in: path required: true schema: type: string - description: Tool name (e.g. graph_stats, explain_request) requestBody: required: false content: @@ -708,36 +551,28 @@ paths: schema: type: object additionalProperties: true - description: Tool-specific parameters as a JSON object. Empty object or empty body for tools with no required params. responses: '200': - description: Tool executed successfully + description: Tool output content: application/json: schema: type: object additionalProperties: true - description: Tool-specific structured output. All tools include schema_version field. '400': - description: Invalid params or missing tool name + description: Invalid params '404': description: Tool not found - '405': - description: Method Not Allowed - '413': - description: Request Entity Too Large '422': - description: Tool execution failed (invalid params, empty result) + description: Tool execution failed '503': - description: Store not configured + description: Store unavailable /healthz: get: - summary: Health check (JSON) - description: | - Always returns HTTP 200 with a JSON status summary. - Shows "ok" or "degraded" status, uptime, store size, and replay state. - Use /readyz for traffic gating, /livez for liveness. + tags: [Health] + operationId: getHealth + summary: Health check responses: '200': description: Health status @@ -745,47 +580,13 @@ paths: application/json: schema: type: object - properties: - status: - type: string - enum: [ok, degraded] - uptime: - type: string - ready: - type: boolean - store: - type: object - properties: - configured: - type: boolean - nodes: - type: integer - edges: - type: integer - event_log: - type: object - properties: - enabled: - type: boolean - replay: - type: object - properties: - status: - type: string - enum: [none, ok, failed] - error: - type: string - last_attempt: - type: string - format: date-time - last_success: - type: string - format: date-time + additionalProperties: true /livez: get: + tags: [Health] + operationId: getLiveness summary: Liveness probe - description: Always returns 200 if the process is alive. Use for K8s livenessProbe. responses: '200': description: OK @@ -797,10 +598,9 @@ paths: /readyz: get: + tags: [Health] + operationId: getReadiness summary: Readiness probe - description: | - Returns 200 when the server is ready to accept traffic, 503 otherwise. - Use for K8s readinessProbe. responses: '200': description: Ready @@ -814,11 +614,12 @@ paths: /metrics: get: + tags: [Health] + operationId: getMetrics summary: Prometheus metrics - description: Returns all waylog_* metrics in Prometheus text exposition format. responses: '200': - description: Prometheus metrics + description: Prometheus exposition content: text/plain: schema: @@ -830,142 +631,717 @@ components: type: apiKey in: header name: X-API-Key - description: API key passed via the X-API-Key header + description: API key supplied via X-API-Key. Required for write or read operations when the corresponding server key is configured. BearerAuth: type: http scheme: bearer - description: API key passed via the Authorization header as a Bearer token + description: Bearer token alternative to X-API-Key. Required for write or read operations when the corresponding server key is configured. + + parameters: + EventID: + name: event_id + in: path + required: true + schema: + type: string + TraceIDPath: + name: trace_id + in: path + required: true + schema: + type: string + TraceIDQuery: + name: trace_id + in: query + schema: + type: string + Service: + name: service + in: query + schema: + type: string + StatusCSV: + name: status + in: query + schema: + type: string + description: "Comma-separated status list: ok,error,timeout,partial,aborted,suppressed." + ErrorStatusCSV: + name: status + in: query + schema: + type: string + description: "Comma-separated failure status list: error,timeout,partial,aborted." + ErrorCode: + name: error_code + in: query + schema: + type: string + Since: + name: since + in: query + schema: + type: string + format: date-time + Until: + name: until + in: query + schema: + type: string + format: date-time + Window: + name: window + in: query + schema: + type: string + description: Go duration string such as 15m or 1h. + IncludeSuppressed: + name: include_suppressed + in: query + schema: + type: boolean + default: false + Cursor: + name: cursor + in: query + schema: + type: string + description: Opaque cursor from next_cursor. + Limit: + name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 200 + + responses: + ReadBadRequest: + description: Invalid query or request + content: + application/json: + schema: + $ref: '#/components/schemas/ReadError' + ReadNotFound: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/ReadError' + schemas: - Problem: + IngestEnvelope: type: object + required: [accepted, duplicate, rejected, deprecations] + example: + accepted: 1 + duplicate: 0 + rejected: [] + deprecations: {} properties: - type: + accepted: + type: integer + duplicate: + type: integer + rejected: + type: array + items: + $ref: '#/components/schemas/RejectedEvent' + deprecations: + type: object + additionalProperties: + type: integer + + RejectedEvent: + type: object + required: [index, reason] + properties: + index: + type: integer + event_id: type: string - title: + reason: type: string - status: - type: integer + enum: + - invalid_json + - schema_validation_failed + - bridge_not_implemented + - batch_oversize + - body_oversize + - unsupported_content_type + - unsupported_content_encoding + - invalid_body + - durability_unavailable detail: type: string - instance: - type: string + + ReadError: + type: object + required: [error] + example: + error: + code: invalid_query + message: window must be a valid duration + properties: + error: + type: object + required: [code, message] + properties: + code: + type: string + message: + type: string + detail: + type: string + WideEvent: type: object - required: - - schema_version - - event_name - - timestamp - - user - - request - - system - - outcome - - metrics + required: [schema_version, event_id, ts_start, ts_end, kind, service, env, trace_id, status] + example: + schema_version: "2.0" + event_id: 11111111-1111-4111-8111-111111111111 + ts_start: "2026-05-04T10:00:00Z" + ts_end: "2026-05-04T10:00:00.042Z" + duration_ms: 42 + kind: http + service: checkout + env: demo + version: local + trace_id: 7f3a2b9c000000000000000000000001 + span_id: 0000000000000001 + status: error + anchor: + step: payment.charge + error_code: PMT_502 + kind: downstream + steps: + - name: cart.validate + start_ms: 0 + duration_ms: 1 + status: ok + - name: db.load_cart + start_ms: 1 + duration_ms: 4 + status: ok + downstream: + service: db + endpoint: /cart/X1 + kind: http + - name: inventory.reserve + start_ms: 5 + duration_ms: 2 + status: ok + - name: payment.charge + start_ms: 7 + duration_ms: 35 + status: error + downstream: + service: payment + endpoint: /charge + kind: http + error: + code: PMT_502 + reason: upstream gateway 5xx + logs: + - ts_offset_ms: 5 + level: info + msg: inventory reserved + - ts_offset_ms: 41 + level: error + msg: upstream gateway 5xx + fields: + demo: + scenario: payment_502 + sku: X1 + errors: + - code: PMT_502 + reason: upstream gateway 5xx properties: schema_version: type: string - description: 'Schema version. Ingest accepts any 1.x. Current emitted: 1.1.' - example: "1.1" - event_name: + enum: ["2.0"] + event_id: type: string - timestamp: + format: uuid + ts_start: type: string format: date-time - user: - $ref: '#/components/schemas/UserContext' - request: - $ref: '#/components/schemas/RequestContext' - system: - $ref: '#/components/schemas/SystemContext' - outcome: - $ref: '#/components/schemas/OutcomeContext' + ts_end: + type: string + format: date-time + duration_ms: + type: integer + minimum: 0 + kind: + type: string + enum: [http] + service: + type: string + env: + type: string + version: + type: string + trace_id: + type: string + pattern: '^[0-9a-f]{32}$' + span_id: + type: string + pattern: '^([0-9a-f]{16})?$' + parent_span_id: + type: string + pattern: '^([0-9a-f]{16})?$' + status: + type: string + enum: [ok, error, timeout, partial, aborted, suppressed] + anchor: + $ref: '#/components/schemas/Anchor' + steps: + type: array + items: + $ref: '#/components/schemas/EventStep' + logs: + type: array + items: + $ref: '#/components/schemas/EventLog' + fields: + type: object + additionalProperties: true + errors: + type: array + items: + $ref: '#/components/schemas/ErrorRef' + + Anchor: + type: object + required: [step, error_code] + properties: + step: + type: string + error_code: + type: string + kind: + type: string + + EventStep: + type: object + required: [name, start_ms, duration_ms, status] + properties: + name: + type: string + span_id: + type: string + start_ms: + type: integer + minimum: 0 + duration_ms: + type: integer + minimum: 0 + status: + type: string + enum: [ok, error] + downstream: + $ref: '#/components/schemas/Downstream' error: - $ref: '#/components/schemas/ErrorContext' - metrics: - $ref: '#/components/schemas/MetricsContext' - parent_request_id: + type: object + properties: + code: + type: string + reason: + type: string + cause: + type: string + + Downstream: + type: object + properties: + service: + type: string + endpoint: type: string - description: 'Schema 1.1. Optional cross-trace link to a parent request (e.g. job → triggering HTTP request). Display-only.' - metadata: + kind: + type: string + + EventLog: + type: object + required: [ts_offset_ms, level, msg] + properties: + ts_offset_ms: + type: integer + minimum: 0 + level: + type: string + enum: [info, warn, error] + msg: + type: string + fields: type: object additionalProperties: true - description: 'Schema 1.1. Free-form application-specific attributes. Display-only.' - retry: - $ref: '#/components/schemas/RetryContext' - UserContext: + + ErrorRef: + type: object + required: [code] + properties: + code: + type: string + reason: + type: string + + EventGetResponse: type: object - required: [id, tier, region, vip] + required: [event] properties: - id: { type: string } - tier: { type: string } - region: { type: string } - vip: { type: boolean } - RequestContext: + event: + $ref: '#/components/schemas/WideEvent' + + EventSearchResponse: type: object - required: [trace_id, flow, feature_flags] + required: [events] + example: + events: + - schema_version: "2.0" + event_id: 11111111-1111-4111-8111-111111111111 + ts_start: "2026-05-04T10:00:00Z" + ts_end: "2026-05-04T10:00:00.042Z" + kind: http + service: checkout + env: demo + trace_id: 7f3a2b9c000000000000000000000001 + status: error + next_cursor: cur_01 properties: - trace_id: { type: string } - span_id: { type: string } - parent_span_id: { type: string } - http_method: + events: + type: array + items: + $ref: '#/components/schemas/WideEvent' + next_cursor: type: string - description: HTTP method (GET, POST, etc.). Optional; auto-captured by SDK middleware. - route_template: + nullable: true + + TraceGetResponse: + type: object + required: [trace_id, events, linkage] + example: + trace_id: 7f3a2b9c000000000000000000000001 + events: [] + linkage: causal + properties: + trace_id: type: string - description: Route template (e.g. /users/{id}). Optional; auto-captured from mux pattern or explicitly set. - flow: { type: string } - feature_flags: + events: type: array - items: { type: string } - correlation_id: + items: + $ref: '#/components/schemas/WideEvent' + linkage: + type: string + enum: [causal, timestamp_fallback, direct] + + TraceSummary: + type: object + required: [trace_id, ts_start, duration_ms, services, status] + properties: + trace_id: type: string - description: Optional correlation ID for cross-request tracing. - attempt: + ts_start: + type: string + format: date-time + duration_ms: type: integer - description: Optional retry attempt number. - transport_kind: + services: + type: array + items: + type: string + status: + type: string + enum: [ok, error, timeout, partial, aborted, suppressed] + anchor_summary: type: string - description: Optional transport type indicator (e.g. "http", "kafka"). - SystemContext: + nullable: true + + RecentTracesResponse: type: object - required: [service, version, deployment_id, env] + required: [traces] + example: + traces: + - trace_id: 7f3a2b9c000000000000000000000001 + ts_start: "2026-05-04T10:00:00Z" + duration_ms: 42 + services: [api-gateway, checkout, payment] + status: error + anchor_summary: checkout:payment.charge:PMT_502 + next_cursor: cur_01 properties: - service: { type: string } - version: { type: string } - deployment_id: { type: string } - env: { type: string } - downstream_service: { type: string } - caller_service: { type: string } - OutcomeContext: + traces: + type: array + items: + $ref: '#/components/schemas/TraceSummary' + next_cursor: + type: string + nullable: true + + StoryResponse: type: object - required: [success, status_code, kind] + required: [trace_id, service, route, status, path, logs, downstream, linkage] + example: + trace_id: 7f3a2b9c000000000000000000000001 + service: checkout + route: /purchase + status: error + anchor: + step: payment.charge + error_code: PMT_502 + path: + - name: cart.validate + start_ms: 0 + duration_ms: 1 + status: ok + - name: db.load_cart + start_ms: 1 + duration_ms: 4 + status: ok + - name: inventory.reserve + start_ms: 5 + duration_ms: 2 + status: ok + - name: payment.charge + start_ms: 7 + duration_ms: 35 + status: error + error_code: PMT_502 + error_msg: upstream gateway 5xx + logs: + - ts_offset_ms: 5 + level: info + msg: inventory reserved + step: inventory.reserve + - ts_offset_ms: 41 + level: error + msg: upstream gateway 5xx + step: payment.charge + downstream: + - step: db.load_cart + service: db + endpoint: /cart/X1 + - step: payment.charge + service: payment + endpoint: /charge + linkage: causal properties: - success: { type: boolean } - status_code: { type: integer } - kind: { type: string } - ErrorContext: + trace_id: + type: string + service: + type: string + route: + type: string + status: + type: string + enum: [ok, error, timeout, partial, aborted, suppressed] + anchor: + $ref: '#/components/schemas/StoryAnchor' + path: + type: array + items: + $ref: '#/components/schemas/StoryStep' + logs: + type: array + items: + $ref: '#/components/schemas/StoryLog' + downstream: + type: array + items: + $ref: '#/components/schemas/StoryDownstream' + linkage: + type: string + enum: [causal, timestamp_fallback, direct] + + StoryAnchor: type: object - required: [code, message] + required: [step, error_code] properties: - code: { type: string } - path: + step: type: string - description: 'Schema 1.1. Runbook URL or doc path operators can open to triage this code.' - message: { type: string } - reason: + error_code: + type: string + + StoryStep: + type: object + required: [name, start_ms, duration_ms, status] + properties: + name: + type: string + start_ms: + type: integer + duration_ms: + type: integer + status: + type: string + enum: [ok, error] + error_code: + type: string + error_msg: + type: string + + StoryLog: + type: object + required: [ts_offset_ms, level, msg] + properties: + ts_offset_ms: + type: integer + level: type: string - description: 'Schema 1.1. Short operator-facing explanation of the failure.' - RetryContext: + enum: [info, warn, error] + msg: + type: string + step: + type: string + + StoryDownstream: + type: object + required: [step, service] + properties: + step: + type: string + service: + type: string + endpoint: + type: string + + ErrorFamily: type: object - description: 'Schema 1.1. Retry metadata. Attempt number lives in request.attempt; together they read "attempt N of M". Validation: retry.of must be 0 or >= request.attempt.' + required: [service, step, error_code] properties: - of: + service: + type: string + step: + type: string + error_code: + type: string + + ErrorRow: + type: object + required: [error_family, count, affected_traces, sample_traces] + properties: + error_family: + $ref: '#/components/schemas/ErrorFamily' + count: + type: integer + affected_users: type: integer - description: 'Total expected attempts. 0 means unknown total.' - previous_attempt_id: + nullable: true + affected_traces: + type: integer + sample_traces: + type: array + items: + type: string + + ErrorsResponse: + type: object + required: [window, rows] + example: + window: 15m + rows: + - error_family: + service: checkout + step: payment.charge + error_code: PMT_502 + count: 12 + affected_users: 8 + affected_traces: 12 + sample_traces: + - 7f3a2b9c000000000000000000000001 + next_cursor: null + properties: + window: + type: string + rows: + type: array + items: + $ref: '#/components/schemas/ErrorRow' + next_cursor: type: string - description: 'Span/request ID of the previous attempt for sibling linking.' - MetricsContext: + nullable: true + + BlastKey: type: object - required: [latency_ms] + required: [error_code] properties: - latency_ms: { type: integer } + service: + type: string + step: + type: string + error_code: + type: string + + BlastRadiusResponse: + type: object + required: [key, view_mode, window, affected_requests, affected_services, top_services, sample_traces] + example: + key: + service: checkout + step: payment.charge + error_code: PMT_502 + view_mode: single_family + window: 15m + affected_requests: 12 + affected_users: 8 + affected_services: 3 + top_services: [api-gateway, checkout, payment] + sample_traces: + - 7f3a2b9c000000000000000000000001 + properties: + key: + $ref: '#/components/schemas/BlastKey' + view_mode: + type: string + enum: [single_family, cross_family] + window: + type: string + affected_requests: + type: integer + affected_users: + type: integer + nullable: true + affected_services: + type: integer + top_services: + type: array + items: + type: string + sample_traces: + type: array + items: + type: string + + CapabilitiesResponse: + type: object + example: + otlp: + http_traces: true + v2_reads: + enabled: true + graph: false + properties: + ask: + type: object + additionalProperties: true + dashboard: + type: object + additionalProperties: true + links: + type: object + additionalProperties: true + graph: + type: boolean + otlp: + type: object + properties: + http_traces: + type: boolean + v2_reads: + type: object + properties: + enabled: + type: boolean + architecture: + type: object + additionalProperties: true diff --git a/examples/microdemo/gateway.go b/examples/microdemo/gateway.go index 9d22ea0..a6a7fac 100644 --- a/examples/microdemo/gateway.go +++ b/examples/microdemo/gateway.go @@ -152,9 +152,6 @@ func parsePurchaseRequest(r *http.Request) (PurchaseRequest, error) { if scenario := r.URL.Query().Get("scenario"); scenario != "" { req.Scenario = scenario } - if force := r.URL.Query().Get("force"); force != "" { - req.Scenario = legacyForceScenario(force) - } if req.SKU == "" { req.SKU = "X1" } diff --git a/examples/microdemo/scenario.go b/examples/microdemo/scenario.go index 37d1f56..b83c699 100644 --- a/examples/microdemo/scenario.go +++ b/examples/microdemo/scenario.go @@ -26,17 +26,6 @@ func normalizeScenario(s string) string { } } -func legacyForceScenario(force string) string { - switch force { - case "", "success": - return ScenarioHappy - case "payment_fail", "payment_retry": - return ScenarioPayment502 - default: - return "" - } -} - func setDemoFields(ctx context.Context, service string, req PurchaseRequest) { waylogv2.SetField(ctx, "user", map[string]any{ "id": demoUserID, diff --git a/scripts/demo-agent-triage.sh b/scripts/demo-agent-triage.sh deleted file mode 100755 index 49fa777..0000000 --- a/scripts/demo-agent-triage.sh +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env bash -# demo-agent-triage.sh — Scenario: agent triage workflow via curl -# Demonstrates: graph_insights → failure_patterns → explain_request → blast_radius -# All calls use Idempotency-Key headers. No LLM dependency. -set -euo pipefail -source "$(dirname "$0")/_lib.sh" - -inject_event() { - local service="$1" success="$2" status_code="$3" error_code="${4:-}" - local trace_id span_id - trace_id=$(rand_hex 32) - span_id=$(rand_hex 16) - - local event_name="${service}.request" - if [[ "$success" == "false" ]]; then - event_name="${service}.error" - fi - - local latency=$((RANDOM % 400 + 20)) - local user_id="user-$(( RANDOM % 100 ))" - local tiers=("free" "premium" "enterprise") - local tier="${tiers[$(( RANDOM % 3 ))]}" - local regions=("us-east" "us-west" "eu-west") - local region="${regions[$(( RANDOM % 3 ))]}" - - local error_block="" - if [[ -n "$error_code" ]]; then - error_block=",\"error\":{\"code\":\"${error_code}\",\"message\":\"error\"}" - fi - - curl -s -o /dev/null -X POST "${BASE_URL}/v1/events" \ - -H "Content-Type: application/json" \ - -d "{ - \"schema_version\":\"1.0\", - \"event_name\":\"${event_name}\", - \"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\", - \"system\":{\"service\":\"${service}\",\"env\":\"demo\",\"version\":\"1.0.0\",\"deployment_id\":\"deploy_${service}\"}, - \"request\":{\"trace_id\":\"${trace_id}\",\"span_id\":\"${span_id}\",\"flow\":\"purchase\"}, - \"user\":{\"id\":\"${user_id}\",\"tier\":\"${tier}\",\"region\":\"${region}\"}, - \"outcome\":{\"status_code\":${status_code},\"success\":${success}}, - \"metrics\":{\"latency_ms\":${latency}}${error_block} - }" -} - -# Usage: do_tool -# Sets: RESP_BODY, RESP_STATUS -do_tool() { - local tool="$1" body="$2" idemp_key="$3" - local raw - raw=$(curl -s -w "\n%{http_code}" \ - -X POST "${BASE_URL}/v1/tools/${tool}" \ - -H "Content-Type: application/json" \ - -H "Idempotency-Key: ${idemp_key}" \ - -d "$body") - RESP_STATUS=$(echo "$raw" | tail -1) - RESP_BODY=$(echo "$raw" | sed '$d') -} - -echo "╔══════════════════════════════════════════════════════════╗" -echo "║ Demo: Agent Triage — Tool Call Workflow ║" -echo "╚══════════════════════════════════════════════════════════╝" -echo "" - -# ── Phase 1: Setup ────────────────────────────────────────── -wait_ready - -echo "" -echo "» Phase 1: Injecting failure scenario..." -for i in $(seq 1 30); do inject_event "api-gateway" "true" "200"; done -for i in $(seq 1 25); do inject_event "payment-service" "false" "502" "PMT_502"; done -for i in $(seq 1 10); do inject_event "checkout-service" "false" "500" "CHK_TIMEOUT"; done -echo " Sent 65 events (30 healthy, 35 failures)." -sleep 2 - -# ── Phase 2: Agent Triage Steps ───────────────────────────── -echo "" -echo "» Step 1/4: graph_insights (with Idempotency-Key)" -do_tool "graph_insights" '{"window":"10m"}' "triage-step-1" -check_status "graph_insights" "$RESP_STATUS" "200" - -total_failures=$(jq_extract '.total_failures' "$RESP_BODY") -echo " total_failures: ${total_failures:-}" -if command -v jq &>/dev/null; then - top_errors=$(echo "$RESP_BODY" | jq -r '.top_errors // empty' 2>/dev/null || echo "") - if [[ -n "$top_errors" ]]; then - echo " top_errors: $top_errors" - fi -fi - -echo "" -echo "» Step 2/4: failure_patterns (with Idempotency-Key)" -do_tool "failure_patterns" '{"window":"10m","limit":5}' "triage-step-2" -check_status "failure_patterns" "$RESP_STATUS" "200" - -if command -v jq &>/dev/null; then - pattern_count=$(echo "$RESP_BODY" | jq '.patterns | length' 2>/dev/null || echo "0") - echo " patterns found: $pattern_count" - echo "$RESP_BODY" | jq -r '.patterns[] | " \(.error_code): \(.count) occurrences"' 2>/dev/null || true -fi - -# Grab a trace_id for explain_request from recent failed traces -example_trace="" -recent_raw=$(curl -s -w "\n%{http_code}" "${BASE_URL}/v1/traces/recent?failures_only=true&limit=1") -recent_status=$(echo "$recent_raw" | tail -1) -recent_body=$(echo "$recent_raw" | sed '$d') -if [[ "$recent_status" == "200" ]]; then - example_trace=$(jq_extract '.traces[0].trace_id' "$recent_body") -fi -if [[ -n "$example_trace" && "$example_trace" != "null" ]]; then - echo " example trace for drilldown: ${example_trace}" -fi - -echo "" -echo "» Step 3/4: explain_request (trace from patterns)" -if [[ -n "$example_trace" && "$example_trace" != "null" ]]; then - do_tool "explain_request" "{\"trace_id\":\"${example_trace}\"}" "triage-step-3" - check_status "explain_request" "$RESP_STATUS" "200" - if command -v jq &>/dev/null; then - echo "$RESP_BODY" | jq -r '" verdict: \(.verdict // "n/a")"' 2>/dev/null || true - echo "$RESP_BODY" | jq -r '" root_cause: \(.root_cause // "n/a")"' 2>/dev/null || true - fi -else - echo " SKIP: no trace_id available from failure_patterns" -fi - -echo "" -echo "» Step 4/4: blast_radius for PMT_502 (with Idempotency-Key)" -do_tool "blast_radius" '{"error_code":"PMT_502","window":"10m","include_services":true}' "triage-step-4" -check_status "blast_radius" "$RESP_STATUS" "200" - -affected_requests=$(jq_extract '.affected_requests' "$RESP_BODY") -affected_users=$(jq_extract '.affected_users' "$RESP_BODY") -severity=$(jq_extract '.severity_score' "$RESP_BODY") -echo " affected_requests: ${affected_requests:-}" -echo " affected_users: ${affected_users:-}" -echo " severity_score: ${severity:-}" - -# ── Phase 3: Idempotency Verification ─────────────────────── -echo "" -echo "» Idempotency check: replay step 1 with same key..." -do_tool "graph_insights" '{"window":"10m"}' "triage-step-1" -check_status "idempotency replay (same key+body)" "$RESP_STATUS" "200" - -echo "" -echo "» Idempotency check: conflict (same key, different body)..." -do_tool "graph_insights" '{"window":"5m"}' "triage-step-1" -check_status "idempotency conflict (same key, diff body)" "$RESP_STATUS" "409" - -# ── Summary ────────────────────────────────────────────────── -echo "" -print_results diff --git a/scripts/demo-cascade-failure.sh b/scripts/demo-cascade-failure.sh deleted file mode 100755 index 4cb1688..0000000 --- a/scripts/demo-cascade-failure.sh +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env bash -# demo-cascade-failure.sh — Watch a single failure propagate across three services. -# -# Scenario: -# api-gateway → checkout-service → payment-service -# payment's database times out. Everything upstream turns red. -# Waylog turns the event stream into a single rendered propagation chain. -# -# This is the flagship demo for the "impact analysis engine" framing. -# It tells a story rather than dumping raw API responses. -set -euo pipefail -source "$(dirname "$0")/_lib.sh" - -send_event() { - curl -s -o /dev/null -w "%{http_code}" \ - -X POST "${BASE_URL}/v1/events" \ - -H "Content-Type: application/json" \ - -d "$1" -} - -# ───────────────────────────────────────────────────────────── -# Intro -# ───────────────────────────────────────────────────────────── -banner "WAYLOG DEMO · cascade failure" -cat <<'INTRO' - - Three services. One broken dependency. One rendered answer. - - api-gateway → checkout-service → payment-service - └─ database timeout - - In a log-first world you would grep three services, correlate - by trace_id, and reconstruct the chain in your head. - - Waylog builds a live graph from the event stream. In a moment - you will see it traverse that graph and print the propagation - chain for a real failing trace. - -INTRO - -# ───────────────────────────────────────────────────────────── -# Stage 1: wait for stack -# ───────────────────────────────────────────────────────────── -section "[1/4] waiting for the stack" -wait_ready - -# ───────────────────────────────────────────────────────────── -# Stage 2: healthy baseline -# ───────────────────────────────────────────────────────────── -section "[2/4] sending 30 healthy baseline purchases" -now=$(date -u +%Y-%m-%dT%H:%M:%SZ) -for i in $(seq 1 30); do - trace_id=$(rand_hex 32) - span_id=$(rand_hex 16) - - rc=$(send_event "{ - \"schema_version\": \"1.0\", - \"event_name\": \"api-gateway.request\", - \"timestamp\": \"${now}\", - \"system\": {\"service\": \"api-gateway\", \"env\": \"demo\"}, - \"request\": {\"trace_id\": \"${trace_id}\", \"span_id\": \"${span_id}\", \"flow\": \"purchase\"}, - \"user\": {\"id\": \"user-baseline-${i}\", \"tier\": \"free\", \"region\": \"us-east-1\"}, - \"outcome\": {\"status_code\": 200, \"success\": true}, - \"metrics\": {\"latency_ms\": $((50 + RANDOM % 100))} - }") - - if [[ "$rc" != "202" && "$rc" != "200" ]]; then - echo " WARN: baseline event $i returned HTTP $rc" - fi -done -echo " done (30 events, all 200 OK)" - -# ───────────────────────────────────────────────────────────── -# Stage 3: register a deploy, then inject cascading failures -# ───────────────────────────────────────────────────────────── -section "[3/4] registering a payment-service deploy, then injecting failures" - -# Register a deploy right before the failures so the dashboard's -# "What Changed" panel and the correlation line downstream have -# something real to point at. Skipped silently if cold store is off. -deploy_id="deploy-cascade-$(rand_hex 8)" -curl -s -o /dev/null -X POST "${BASE_URL}/v1/deployments" \ - -H "Content-Type: application/json" \ - -d "{\"id\":\"${deploy_id}\",\"service\":\"payment-service\",\"version\":\"v1.4.2\",\"env\":\"demo\"}" \ - || true -echo " deploy registered: payment-service v1.4.2 (${deploy_id})" -echo "" -echo " gateway UPSTREAM_502 → checkout DB_TIMEOUT → payment DB_TIMEOUT" -last_trace_id="" - -for i in $(seq 1 15); do - trace_id=$(rand_hex 32) - last_trace_id="$trace_id" - - root_span=$(rand_hex 16) - checkout_span=$(rand_hex 16) - payment_span=$(rand_hex 16) - - ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) - - # Span 1: api-gateway (root) — 502 UPSTREAM_502 - send_event "{ - \"schema_version\": \"1.0\", - \"event_name\": \"api-gateway.error\", - \"timestamp\": \"${ts}\", - \"system\": {\"service\": \"api-gateway\", \"env\": \"demo\"}, - \"request\": {\"trace_id\": \"${trace_id}\", \"span_id\": \"${root_span}\", \"flow\": \"purchase\"}, - \"user\": {\"id\": \"user-cascade-${i}\", \"tier\": \"premium\", \"region\": \"us-east-1\"}, - \"outcome\": {\"status_code\": 502, \"success\": false}, - \"error\": {\"code\": \"UPSTREAM_502\", \"message\": \"upstream checkout-service returned 502\"}, - \"metrics\": {\"latency_ms\": $((3000 + RANDOM % 2000))} - }" > /dev/null - - # Span 2: checkout-service (child) — 502 DB_TIMEOUT - send_event "{ - \"schema_version\": \"1.0\", - \"event_name\": \"checkout-service.error\", - \"timestamp\": \"${ts}\", - \"system\": {\"service\": \"checkout-service\", \"env\": \"demo\", \"caller_service\": \"api-gateway\"}, - \"request\": {\"trace_id\": \"${trace_id}\", \"span_id\": \"${checkout_span}\", \"parent_span_id\": \"${root_span}\", \"flow\": \"purchase\"}, - \"user\": {\"id\": \"user-cascade-${i}\", \"tier\": \"premium\", \"region\": \"us-east-1\"}, - \"outcome\": {\"status_code\": 502, \"success\": false}, - \"error\": {\"code\": \"DB_TIMEOUT\", \"message\": \"database connection timed out after 5000ms\"}, - \"metrics\": {\"latency_ms\": $((5000 + RANDOM % 1000))} - }" > /dev/null - - # Span 3: payment-service (leaf) — 503 DB_TIMEOUT - send_event "{ - \"schema_version\": \"1.0\", - \"event_name\": \"payment-service.error\", - \"timestamp\": \"${ts}\", - \"system\": {\"service\": \"payment-service\", \"env\": \"demo\", \"caller_service\": \"checkout-service\"}, - \"request\": {\"trace_id\": \"${trace_id}\", \"span_id\": \"${payment_span}\", \"parent_span_id\": \"${checkout_span}\", \"flow\": \"purchase\"}, - \"user\": {\"id\": \"user-cascade-${i}\", \"tier\": \"premium\", \"region\": \"us-east-1\"}, - \"outcome\": {\"status_code\": 503, \"success\": false}, - \"error\": {\"code\": \"DB_TIMEOUT\", \"message\": \"database connection pool exhausted\"}, - \"metrics\": {\"latency_ms\": $((5000 + RANDOM % 1000))} - }" > /dev/null -done -echo " done (15 traces × 3 spans = 45 failure events)" - -# ───────────────────────────────────────────────────────────── -# Stage 4: let the graph settle -# ───────────────────────────────────────────────────────────── -section "[4/4] letting the graph settle" -sleep 2 -echo " done" - -# ───────────────────────────────────────────────────────────── -# The answer: rendered propagation chain -# ───────────────────────────────────────────────────────────── -section "PROPAGATION CHAIN (last failing trace)" -curl -s "${BASE_URL}/v1/traces/story?trace_id=${last_trace_id}" | render_chain -echo "" -render_blast_radius "DB_TIMEOUT" "10m" -render_recent_deploy "payment-service" "1h" - -# ───────────────────────────────────────────────────────────── -# What this shows -# ───────────────────────────────────────────────────────────── -section "WHAT THIS SHOWS" -cat <<'EXPLAIN' - - You did not grep three log streams. - You did not correlate by trace_id across services. - You did not ask an LLM to piece it together. - - Waylog built a live graph from the event stream and traversed - it in O(hops). Every answer above is also a deterministic tool - call any agent can make: - - GET /v1/traces/story?trace_id=... ← the chain above - GET /v1/blast_radius?error_code=... ← the radius line - POST /v1/tools/failure_chain ← same thing, tool form - POST /v1/tools/explain_request ← LLM-free root cause - - Next: - • open http://localhost:8080/ui (live dashboard) - • ./scripts/demo-agent-triage.sh (4-step agent workflow) - • ./scripts/demo-comparison.sh (before/after window diff) - -EXPLAIN - -# ───────────────────────────────────────────────────────────── -# Verifications (kept quiet, but fail the script if broken) -# ───────────────────────────────────────────────────────────── -check "Overview API" "${BASE_URL}/v1/overview?window=5m" "200" -check "Trace story for last cascade" "${BASE_URL}/v1/traces/story?trace_id=${last_trace_id}" "200" - -print_results diff --git a/scripts/demo-comparison.sh b/scripts/demo-comparison.sh deleted file mode 100755 index 3a21646..0000000 --- a/scripts/demo-comparison.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -# demo-comparison.sh — Scenario: compare_windows baseline vs current -set -euo pipefail -source "$(dirname "$0")/_lib.sh" - -echo "=== compare_windows Demo: Before/After Error Analysis ===" -echo "" - -# ── Step 1: Wait for stack ready ────────────────────────────────── -wait_ready - -# ── Step 2: Send 40 healthy baseline events ─────────────────────── -echo "" -echo "→ Sending 40 healthy baseline events for api-gateway ..." -BASELINE_TRACE_PREFIX="aaaa0000bbbb0000cccc0000dddd" -for i in $(seq 1 40); do - idx=$(printf "%04d" "$i") - trace_id="${BASELINE_TRACE_PREFIX}${idx}" - span_id="1111000022220${idx}" - ts=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") - - curl -s -o /dev/null -w "" "${BASE_URL}/v1/events" \ - -H "Content-Type: application/json" \ - -d "{ - \"schema_version\": \"1.0\", - \"event_name\": \"api-gateway.request\", - \"timestamp\": \"${ts}\", - \"system\": {\"service\": \"api-gateway\", \"env\": \"demo\"}, - \"request\": {\"trace_id\": \"${trace_id}\", \"span_id\": \"${span_id}\", \"flow\": \"purchase\"}, - \"user\": {\"id\": \"user-baseline-${idx}\", \"tier\": \"free\", \"region\": \"us-east-1\"}, - \"outcome\": {\"status_code\": 200, \"success\": true}, - \"metrics\": {\"latency_ms\": $((50 + RANDOM % 100))} - }" -done -echo " Sent 40 healthy events (status_code=200, success=true)." - -# ── Step 3: Time separation ─────────────────────────────────────── -echo "" -echo "→ Sleeping 5s for time separation between windows ..." -sleep 5 - -# ── Step 4: Send 20 failure events with NEW_ERROR_CODE ──────────── -echo "" -echo "→ Sending 20 failure events with error_code=NEW_ERROR_CODE ..." -FAILURE_TRACE_PREFIX="ffff1111eeee2222dddd3333cccc" -for i in $(seq 1 20); do - idx=$(printf "%04d" "$i") - trace_id="${FAILURE_TRACE_PREFIX}${idx}" - span_id="aaaa3333bbbb4${idx}" - ts=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") - - curl -s -o /dev/null -w "" "${BASE_URL}/v1/events" \ - -H "Content-Type: application/json" \ - -d "{ - \"schema_version\": \"1.0\", - \"event_name\": \"api-gateway.error\", - \"timestamp\": \"${ts}\", - \"system\": {\"service\": \"api-gateway\", \"env\": \"demo\"}, - \"request\": {\"trace_id\": \"${trace_id}\", \"span_id\": \"${span_id}\", \"flow\": \"purchase\"}, - \"user\": {\"id\": \"user-fail-${idx}\", \"tier\": \"paid\", \"region\": \"us-east-1\"}, - \"outcome\": {\"status_code\": 502, \"success\": false}, - \"metrics\": {\"latency_ms\": $((500 + RANDOM % 300))}, - \"error\": {\"code\": \"NEW_ERROR_CODE\", \"message\": \"upstream dependency timed out\"} - }" -done -echo " Sent 20 failure events (error_code=NEW_ERROR_CODE, status_code=502)." - -# Allow graph to settle -sleep 2 - -# ── Step 5: Verify tool list endpoint ───────────────────────────── -echo "" -echo "→ Verifying tool list endpoint ..." -check "GET /v1/tools" "${BASE_URL}/v1/tools" "200" - -# ── Step 6: Call compare_windows tool ───────────────────────────── -echo "" -echo "→ Calling compare_windows: current=2m, baseline=2m, offset=10s ..." -compare_resp=$(curl -s -w "\n%{http_code}" "${BASE_URL}/v1/tools/compare_windows" \ - -H "Content-Type: application/json" \ - -d '{"current":"2m","baseline":"2m","offset":"10s"}') - -compare_body=$(echo "$compare_resp" | sed '$d') -compare_status=$(echo "$compare_resp" | tail -1) - -check_status "POST /v1/tools/compare_windows returns 200" "$compare_status" "200" - -# ── Step 7: Print comparison result ─────────────────────────────── -echo "" -echo "=== compare_windows Result ===" -echo "$compare_body" | pretty_json - -if command -v jq &>/dev/null; then - echo "" - echo "--- Highlights ---" - echo "$compare_body" | jq -r ' - if .result then .result else . end | - if type == "string" then . else - "New error codes: \(.new_error_codes // .new_errors // "n/a")", - "Increased error codes: \(.increased // "n/a")", - "Failures (baseline): \(.total_failures_before // .baseline_failures // "n/a")", - "Failures (current): \(.total_failures_after // .current_failures // "n/a")" - end - ' 2>/dev/null || echo "(could not extract highlights)" -fi - -# ── Step 8: Print dashboard URL ─────────────────────────────────── -echo "" -echo "=== Dashboard ===" -echo " Web UI: ${BASE_URL}/ui/" -echo " Overview API: ${BASE_URL}/v1/overview?window=5m" - -# ── Summary ─────────────────────────────────────────────────────── -echo "" -print_results diff --git a/scripts/demo-deploy-failure.sh b/scripts/demo-deploy-failure.sh deleted file mode 100755 index d1e9cac..0000000 --- a/scripts/demo-deploy-failure.sh +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env bash -# demo-deploy-failure.sh — Scenario: deploy webhook → payment 502s → dashboard -set -euo pipefail -source "$(dirname "$0")/_lib.sh" - -send_event() { - local trace_id="$1" - local span_id="$2" - local service="$3" - local success="$4" - local status_code="$5" - local latency_ms="$6" - local deployment_id="${7:-}" - local error_code="${8:-}" - local error_message="${9:-}" - local version="${10:-v2.0.0}" - - local event_name="${service}.request" - if [[ "$success" == "false" ]]; then - event_name="${service}.error" - fi - - local ts - ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - - local error_block="" - if [[ -n "$error_code" ]]; then - error_block=$(printf ',"error":{"code":"%s","message":"%s"}' "$error_code" "$error_message") - fi - - local body - body=$(printf '{ - "schema_version":"1.0", - "event_name":"%s", - "timestamp":"%s", - "system":{"service":"%s","env":"prod","version":"%s","deployment_id":"%s"}, - "request":{"trace_id":"%s","span_id":"%s","flow":"purchase"}, - "user":{"id":"user_%s","tier":"premium","region":"us-east-1"}, - "outcome":{"status_code":%d,"success":%s}, - "metrics":{"latency_ms":%d}%s - }' "$event_name" "$ts" "$service" "$version" "$deployment_id" "$trace_id" "$span_id" \ - "$(rand_hex 4)" "$status_code" "$success" "$latency_ms" "$error_block") - - curl -s -o /dev/null -w "" -X POST -H "Content-Type: application/json" -d "$body" "${BASE_URL}/v1/events" -} - -# ------------------------------------------------------------------- -echo "=== Deploy-Failure Demo ===" -echo "" -echo "Target: ${BASE_URL}" -echo "" - -# Step 0: Wait for stack ready -echo "--- Waiting for stack readiness ---" -wait_ready -echo "" - -# Step 1: Send 60 healthy baseline events for payment-service -echo "--- Step 1: Sending 60 healthy baseline events (payment-service v2.0.0) ---" -for i in $(seq 1 60); do - trace_id=$(rand_hex 32) - span_id=$(rand_hex 16) - latency=$((50 + RANDOM % 100)) - send_event "$trace_id" "$span_id" "payment-service" "true" "200" "$latency" "" "" "" -done -echo "Sent 60 healthy events." -echo "" - -# Brief pause so events settle before the deployment marker -sleep 2 - -# Step 2: Register deployment v2.1 -echo "--- Step 2: Registering deployment deploy_v2.1 ---" -deploy_body='{"id":"deploy_v2.1","service":"payment-service","env":"prod","version":"v2.1.0"}' -check_post "POST /v1/deployments (register deploy_v2.1)" "${BASE_URL}/v1/deployments" "$deploy_body" "201" -echo "" - -# Brief pause so deployment timestamp separates before/after windows -sleep 2 - -# Step 3: Send 40 failure events with deployment_id -echo "--- Step 3: Sending 40 failure events (payment-service v2.1.0, PMT_502) ---" -for i in $(seq 1 40); do - trace_id=$(rand_hex 32) - span_id=$(rand_hex 16) - latency=$((200 + RANDOM % 300)) - send_event "$trace_id" "$span_id" "payment-service" "false" "502" "$latency" \ - "deploy_v2.1" "PMT_502" "upstream payment gateway timeout" "v2.1.0" -done -echo "Sent 40 failure events." -echo "" - -# Allow time for ingestion and graph building -sleep 3 - -# Step 4: Verify endpoints -echo "--- Step 4: Verifying API endpoints ---" -check "GET /v1/overview" "${BASE_URL}/v1/overview?window=10m" "200" -check "GET /v1/traces/recent" "${BASE_URL}/v1/traces/recent?limit=10" "200" -check "GET /v1/deployments" "${BASE_URL}/v1/deployments?window=10m" "200" -echo "" - -# Step 5: Print key responses -echo "--- API Response Snapshots ---" -echo "" - -echo ">> Overview (window=10m):" -curl -s "${BASE_URL}/v1/overview?window=10m" | pretty_json -echo "" - -echo ">> Recent Traces (limit=5, failures_only):" -curl -s "${BASE_URL}/v1/traces/recent?limit=5&failures_only=true" | pretty_json -echo "" - -echo ">> Deployments (window=10m):" -curl -s "${BASE_URL}/v1/deployments?window=10m" | pretty_json -echo "" - -# Step 6: Print dashboard URL -echo "--- Dashboard ---" -echo "Open: ${BASE_URL}/ui/" -echo "" - -# Results -print_results From 979d6fb707963b7f0ac752eee5a4b8b79c8ce642 Mon Sep 17 00:00:00 2001 From: skota-hash Date: Mon, 4 May 2026 05:55:49 -0400 Subject: [PATCH 12/12] feat(runtime): Harden v2 runtime and add demo acceptance gate - Added schema-2.0 WAL replay equivalence coverage across recent, errors, story, blast, event, and trace reads. - Assert replay warms event_id dedupe so replayed events are not accepted again. - Add mixed NDJSON durability coverage proving invalid entries are rejected while accepted entries are durable and readable. - Strengthen suppressed-event invariants for search, errors, and blast radius. - Add prune/read coverage to ensure old traces disappear while fresh cursor ordering remains valid. - Added scripts/demo-acceptance.sh and make demo-acceptance to verify the live demo path: burst traffic, capabilities, recent, errors, blast, explain, and event. - Fixed CLI parsing for positional commands with trailing flags in blast and search. - Aligned README, env docs, internals docs, and SDK contract docs with the current schema-2.0 demo/operator flow. - Removed obsolete e2e-mark2 and v1 e2e emitter support path. --- Makefile | 6 +- README.md | 15 +- docs/env.md | 14 +- docs/internals.md | 19 +- docs/waylog-sdk-contract.md | 326 +++++++++++---------------- examples/cmd/e2e-emit/main.go | 102 --------- examples/cmd/e2e-otlp/main.go | 6 +- internal/cli/v2/cmd.go | 144 ++++++++++-- internal/cli/v2/cmd_test.go | 51 +++++ internal/ingest/v2/handler_test.go | 43 ++++ internal/ingest/v2/projector_test.go | 45 ++++ internal/ingest/v2/replay_test.go | 209 +++++++++++++++++ scripts/demo-acceptance-json/main.go | 102 +++++++++ scripts/demo-acceptance.sh | 98 ++++++++ scripts/demo.sh | 8 +- scripts/e2e-mark2.sh | 146 ------------ 16 files changed, 832 insertions(+), 502 deletions(-) delete mode 100644 examples/cmd/e2e-emit/main.go create mode 100644 scripts/demo-acceptance-json/main.go create mode 100755 scripts/demo-acceptance.sh delete mode 100644 scripts/e2e-mark2.sh diff --git a/Makefile b/Makefile index a3437d5..e47aeb7 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ SHELL := /bin/sh -.PHONY: help build build-examples ingest ingest-mcp waylog waylog-live checkout test test-race test-sdk lint ci fmt vet vet-sdk clean kafka-up kafka-down demo demo-stop demo-up demo-down micro-demo micro-demo-stop docker-build docker-up docker-down docker-reset docker-dev docker-prod ts-install ts-build ts-test bench-gate +.PHONY: help build build-examples ingest ingest-mcp waylog waylog-live checkout test test-race test-sdk lint ci fmt vet vet-sdk clean kafka-up kafka-down demo demo-stop demo-acceptance demo-up demo-down micro-demo micro-demo-stop docker-build docker-up docker-down docker-reset docker-dev docker-prod ts-install ts-build ts-test bench-gate help: @echo "Targets:" @@ -18,6 +18,7 @@ help: @echo " kafka-down - stop local Kafka via docker compose" @echo " demo - start dashboard demo locally (detached, no Docker)" @echo " demo-stop - stop demo processes" + @echo " demo-acceptance - verify a running local demo end-to-end" @echo " demo-up - start v2 demo stack in Docker (detached)" @echo " demo-down - stop Docker demo stack" @echo " micro-demo - start 4-service micro-demo in foreground for debugging" @@ -119,6 +120,9 @@ demo: demo-stop: ./scripts/demo-stop.sh +demo-acceptance: + ./scripts/demo-acceptance.sh + demo-up: docker-dev demo-down: docker-down diff --git a/README.md b/README.md index 6fbd917..439170b 100644 --- a/README.md +++ b/README.md @@ -185,14 +185,14 @@ curl -X POST http://localhost:8080/v1/plans/execute \ Plans execute deterministically server-side with SSE progress on `/v1/stream/plans/{id}`. -### Trace story as a tree +### Trace story ```bash -curl "http://localhost:8080/v1/traces/story?trace_id=$TRACE&format=tree" \ +curl "http://localhost:8080/v1/traces/story?trace_id=$TRACE" \ -H "Authorization: Bearer $WAYLOG_READ_KEY" ``` -Returns the nested propagation tree the dashboard renders. Omit `format=tree` for the flat chain. +Returns the first failing step, contributing path, logs, downstream calls, and linkage mode used by the dashboard and `waylog explain`. ### MCP (agent surface) @@ -264,6 +264,7 @@ make fmt vet test # checks make test-race # race detector make ts-test # TypeScript SDK vitest suite make ci # fmt + vet + test-race + test-sdk + ts-test + doc-link + rollup-contract +make demo-acceptance # with make demo running, verify demo + CLI triage loop ``` ## Auth @@ -292,9 +293,9 @@ Public alpha. APIs may break before 1.0. - SQLite cold store (events, deployments, causal claims) - 10 deterministic analysis tools, rollup-correct root-cause attribution - agent-native REST (`/v1/tools/*`, `/v1/ask`, `/v1/plans/execute`) with idempotency and structured envelopes -- `/v1/traces/story?format=tree` and tree rendering in the dashboard -- dashboard: minimal v2 triage loop (errors, explain, blast) with inline SVG mini-graphs -- v2 operator CLI (`errors`, `trace`, `explain`, `blast`, `search`) over read APIs +- `/v1/traces/story` and indented failure-path rendering in the dashboard +- dashboard: minimal v2 triage loop (errors, explain, blast, recent requests) +- v2 operator CLI (`capabilities`, `recent`, `errors`, `event`, `trace`, `explain`, `blast`, `search`) over read APIs - live TUI (`waylog-live --dev` streams via SSE), MCP stdio - scoped auth (write/read/agent) with startup validation @@ -314,4 +315,4 @@ Public alpha. APIs may break before 1.0. - No built-in alerting or paging. Waylog answers questions, it doesn't wake you up. - No multi-tenancy. One instance = one trust boundary. -**Fastest walkthrough:** `make demo`, open , click **Run payment outage**, then use the dashboard or `waylog errors`, `waylog explain`, and `waylog blast` to answer what failed, which downstream was involved, and how broad the impact is. +**Fastest walkthrough:** `make demo`, open , click **Run traffic burst**, then use the dashboard or `waylog recent`, `waylog errors`, `waylog explain`, and `waylog blast` to answer what failed, which downstream was involved, and how broad the impact is. diff --git a/docs/env.md b/docs/env.md index 1931802..f0c77ea 100644 --- a/docs/env.md +++ b/docs/env.md @@ -6,7 +6,7 @@ Reference for configuring the Waylog ingest server and SDK. All variables are re | Variable | Purpose | |---|---| -| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Required for legacy server-side Ask flows | +| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Required when server-side Ask/tool flows use Gemini | ## Auth @@ -73,7 +73,7 @@ See [Internals](internals.md) for the full durability model. | Variable | Default | Purpose | |---|---|---| -| `GRAPH_UI` | `false` | Enable Graph topology tab in dashboard and `/v1/topology` endpoint | +| `GRAPH_UI` | `false` | Enable optional graph topology endpoint `/v1/graph/topology` | | `WAYLOG_V2_READS` | `false` | Route v2 read endpoints to the schema-2.0 recent index | | `CAUSAL_ENABLED` | `false` | Enable shadow-mode causal inference | | `CAUSAL_INTERVAL` | `30s` | Causal inference ticker interval | @@ -82,18 +82,18 @@ See [Internals](internals.md) for the full durability model. ## Schema-2.0 reference demo -`make micro-demo` sets these automatically for the local event-driven demo. +`make demo` sets these automatically for the detached local showcase. `make micro-demo` uses the same services in foreground debug mode. | Variable | Default | Purpose | |---|---|---| | `INGEST_URL` | `http://localhost:8080` | SDK delivery base URL used by the demo services | -| `INGEST_ADDR` | `:8080` | Ingest server listen address | +| `INGEST_ADDR` | `127.0.0.1:8080` in `make demo`; `:8080` in `make micro-demo` | Ingest server listen address | | `WAYLOG_WRITE_KEY` | `demo` | Write-scope key used by the demo SDK emitters | | `WAYLOG_READ_KEY` | `demo` | Read-scope key used by the printed CLI commands | -| `DASHBOARD_AUTH` | `key:demo` | Enables dashboard/read-session auth so `WAYLOG_READ_KEY` is valid at startup | +| `DASHBOARD_AUTH` | `off` in `make demo`; `key:demo` in `make micro-demo` | Dashboard auth mode for the local demo surface | | `WAYLOG_V2_READS` | `true` in demo scripts | Enables v2 read APIs required by `waylog errors/explain/blast` | -The embedded `/ui` dashboard is also v2-only and renders a setup message unless `WAYLOG_V2_READS=true`. +The embedded `/ui` dashboard is a schema-2.0 triage surface and renders a setup message unless `WAYLOG_V2_READS=true`. ## Dashboard links @@ -108,7 +108,7 @@ Optional external links rendered in the dashboard header. Hidden if empty. Pre-baked `.env` files live in [`deploy/`](../deploy): -- `deploy/dev.env` — 100% sampling, graph UI on, causal on, verbose logging +- `deploy/dev.env` — 100% sampling, optional graph endpoints on, causal on, verbose logging - `deploy/prod.env` — 5% happy-path sampling, graph UI off by default, tighter retention Use with `make docker-dev` or `make docker-prod`. diff --git a/docs/internals.md b/docs/internals.md index 7a244b1..b8c318d 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -4,16 +4,16 @@ Mechanics behind the ingest server. If you're adopting Waylog for a service and ## Data flow -1. **SDK / collector** emits a WideEvent over HTTP or Kafka. -2. **Ingest** validates the event, writes it to the event log, and — only if the log write succeeds — merges it into the hot graph. -3. **Hot graph** converts events into nodes (request, user, service, error) and edges. Spans are routed to the TraceStore for drill-down. +1. **SDK / collector** emits a schema-2.0 WideEvent over HTTP, or sends OTLP/HTTP traces that are converted to schema-2.0 events. +2. **Ingest** validates the event, writes it to the schema-2.0 WAL, and — only if the WAL write succeeds — projects it into recent read models. +3. **Derived read models** index events by event, trace, service, error family, and downstream call for `recent`, `errors`, `explain`, and `blast`. 4. **Cold store** (SQLite, optional) persists events, deployments, and causal claims for historical queries. 5. **Snapshot** writes the graph to disk every tick (default 5s) so restarts replay only the tail. -6. **Read path** serves the dashboard, TUI, CLI, MCP, and agent APIs from the same graph. +6. **Read path** serves the dashboard, CLI, MCP, and agent APIs from the same derived data. ## Durability model -The event log is the **source of truth**. The in-memory graph is a derived, queryable view that can be rebuilt from the log. +The event log is the **source of truth**. The in-memory graph and schema-2.0 read indexes are derived, queryable views that can be rebuilt from the log. ### Write path @@ -87,10 +87,9 @@ Custom `prometheus.Registry` per server — no global. All metric calls are guar ## SDK contract -See [`waylog-sdk-contract.md`](waylog-sdk-contract.md) for the complete WideEvent schema. Key points: +See [`waylog-sdk-contract.md`](waylog-sdk-contract.md) for the schema-2.0 SDK contract. Key points: -- `schema_version = "1.0"` (the ingest accepts any `1.x`) -- Event naming: `.request` (success) or `.error` (failure) +- `schema_version = "2.0"` - Trace ID: 32 hex chars. Span ID: 16 hex chars (W3C traceparent) -- `outcome.status_code` must never be 0 -- If `success=false`, `error.code` must be non-empty +- Failed events should include `anchor.step`, `anchor.error_code`, and a matching failed step +- Suppressed events remain queryable only through explicit recent/search/direct trace surfaces and are excluded from errors/blast diff --git a/docs/waylog-sdk-contract.md b/docs/waylog-sdk-contract.md index 9671970..f6bdb12 100644 --- a/docs/waylog-sdk-contract.md +++ b/docs/waylog-sdk-contract.md @@ -1,233 +1,161 @@ -# Waylog SDK Contract (Language-Agnostic) +# Waylog SDK Contract -This document defines the **language-agnostic contract** for any Waylog SDK implementation. -If a service emits events that follow this contract, the ingest pipeline will accept them -and the graph will build correctly—regardless of implementation language. +This document defines the language-agnostic contract for SDKs that emit Waylog +WideEvents. The current implementation contract is schema-2.0. Public release +surface naming is intentionally deferred until the final normalization slice. -## 1) Core Principles +For the machine-checkable schema, see [`schema/v2.0.json`](schema/v2.0.json). -- **Emit-only**: the SDK only emits events; it does not query or analyze. -- **Valid-by-construction**: every emitted event MUST pass `WideEvent.Validate()` rules. -- **Single event per request**: - - Success → `.request` - - Failure → `.error` -- **Context-first**: all request-scoped values come from HTTP middleware. +## Core Rules -## 2) Required Fields (Must Always Be Set) +- The SDK is emit-only. Reads happen through the ingest server, CLI, dashboard, + or agent/tool APIs. +- Every emitted event must be valid schema-2.0 JSON before delivery. +- One request handled by one service emits one WideEvent. +- Request-scoped state flows through middleware context. +- Trace propagation uses W3C `traceparent`. +- Stable operator fields matter more than raw log volume: `service`, `trace_id`, + `status`, `anchor`, `steps`, `logs`, and `fields` drive triage. -**Top-level** -- `schema_version` = `"1.x"` (current: `"1.1"`; ingest accepts any `1.x`) -- `event_name` -- `timestamp` (UTC) +## Required Top-Level Fields -**User** -- `user.id` (if unknown, use `"system"`) +- `schema_version`: currently `"2.0"` +- `event_id`: UUID +- `ts_start`, `ts_end`: UTC timestamps +- `kind`: currently `http` +- `service` +- `env` +- `trace_id`: 32 lowercase hex chars +- `status`: `ok`, `error`, `timeout`, `partial`, `aborted`, or `suppressed` -**Request** -- `request.trace_id` +Recommended fields: -**System** -- `system.service` -- `system.env` +- `duration_ms` +- `version` +- `span_id` +- `parent_span_id` +- `fields.http.method` +- `fields.http.route` +- `fields.http.status` +- `fields.user.id`, `fields.user.tier`, `fields.user.region` -**Outcome** -- `outcome.status_code` (must never be 0) +## Failure Contract -**Failures** -- If `outcome.success=false`, `error` must be present and `error.code` must be non-empty. +Failed events should set: -## 3) Event Naming (Hard Rule) +- `status` to one of `error`, `timeout`, `partial`, or `aborted` +- `anchor.step` to the first operator-meaningful failing step +- `anchor.error_code` to a stable code such as `PMT_502` +- A matching failed item in `steps[]` with `status: "error"` and `error.code` +- `errors[]` with the same stable error code -- **Success**: `.request` -- **Failure**: `.error` +Suppressed events use `status: "suppressed"`. They remain available through +direct event/trace/recent surfaces when explicitly requested, but are excluded +from error rollups and blast radius. -No dynamic values (paths, methods, IDs, status codes). +## Step Contract -## 4) Outcome + Error Rules +`steps[]` is the primary explanation surface. Emit steps at the level an +operator would reason about: -- `outcome.kind = "http"` -- `outcome.success = (status_code < 500)` unless an error was explicitly recorded. -- When an error is recorded: - - `outcome.success = false` - - If `status_code < 400`, **upgrade to 500** - - If `status_code` is 4xx or 5xx, **preserve** -- `error.code` must be stable (fallback to `"UNKNOWN"`) -- `error.message` should be short (no stack traces) +```text +cart.validate → db.load_cart → inventory.reserve → payment.charge → order.commit +``` -## 5) Trace Propagation (HTTP) +Each step should include: -### Inbound (strict W3C) -- Primary: `traceparent` -- Optional legacy fallback (inbound only): - - `x-trace-id` - - `x-span-id` - - `x-request-id` (only if none of the above exist) +- `name` +- `start_ms` +- `duration_ms` +- `status` +- `span_id` when the step represents a downstream call or child span +- `downstream.service` and `downstream.endpoint` for real service calls +- `error.code` and `error.reason` when the step fails -Rules: -- Trace ID: **32 hex chars** -- Span ID: **16 hex chars** -- Accept uppercase; normalize to lowercase. -- Invalid headers are ignored → generate new trace. +## Logs -### Outbound (W3C only) -- Inject `traceparent: 00---01` -- Inject `x-waylog-service: ` +`logs[]` should contain milestone logs useful inside `explain`, not every +application log line. Recommended levels are `info`, `warn`, and `error`. -### Parent/Child -- `parent_span_id` is derived from inbound `traceparent` span. -- `span_id` is newly generated per service. +Good examples: -## 6) Context Fields +- `cart validated` +- `cart loaded` +- `inventory reserved` +- `retrying payment` +- `upstream gateway 5xx` +- `order committed` -**SystemContext** -- `service` (required) -- `env` (required) -- `version` (recommended) -- `deployment_id` (recommended) -- `caller_service` (from inbound `x-waylog-service`, else `external`) -- `downstream_service` (best-effort via HTTP client wrapper) +## Transport -**RequestContext** -- `trace_id` -- `span_id` -- `parent_span_id` -- `http_method` (recommended; auto-captured by SDK middleware from `r.Method`) -- `route_template` (recommended; auto-captured from `r.Pattern` or explicitly set via `WithRouteTemplate`) -- `flow` (optional) -- `feature_flags` (optional list) - -**UserContext** -- `id` (required, default `system`) -- `tier`, `region`, `vip` (optional) - -**MetricsContext** -- `latency_ms` (middleware timer) - -## 7) Transport - -**Primary:** Kafka (topic `wide_events`) -- JSON-encoded `WideEvent` -- Asynchronous + buffered -- Best-effort; must not block request path - -**Alternatives for dev/test:** -- `NopTransport` -- `InMemoryTransport` - -## 8) Emission Lifecycle (Reference Middleware) - -1. **Inbound middleware** - - Parse `traceparent` - - Generate `span_id` - - Start latency timer - - Capture response status (default 200) -2. **Handler executes** - - Application calls `Error(ctx, err)` on failure - - If handler panics: middleware recovers, records `panic: ` as error, emits failure event, then re-panics -3. **Request end** - - Build `WideEvent` - - Validate - - Enqueue to transport - -## 9) JSON Example (Success) +The primary SDK transport is HTTP ingest: -```json -{ - "schema_version": "1.0", - "event_name": "checkout-service.request", - "timestamp": "2026-02-03T19:11:00Z", - "user": {"id": "u123", "tier": "free", "region": "us", "vip": false}, - "request": { - "trace_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "span_id": "bbbbbbbbbbbbbbbb", - "parent_span_id": "cccccccccccccccc", - "flow": "checkout_v2", - "feature_flags": ["new_checkout"] - }, - "system": { - "service": "checkout-service", - "env": "prod", - "version": "1.9.2", - "deployment_id": "deploy_2026_02_03", - "caller_service": "frontend", - "downstream_service": "payment-service" - }, - "outcome": {"success": true, "status_code": 200, "kind": "http"}, - "metrics": {"latency_ms": 123} -} +```text +POST /v1/events +Content-Type: application/json +Authorization: Bearer $WAYLOG_WRITE_KEY ``` -## 10) JSON Example (Failure) +OTLP/HTTP traces can also be sent to: + +```text +POST /v1/otlp/v1/traces +Content-Type: application/x-protobuf +``` + +Kafka and bridge paths are optional/internal integration surfaces, not the +default demo or SDK contract. + +## Minimal Example ```json { - "schema_version": "1.0", - "event_name": "checkout-service.error", - "timestamp": "2026-02-03T19:11:00Z", - "user": {"id": "u123"}, - "request": { - "trace_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "span_id": "bbbbbbbbbbbbbbbb", - "parent_span_id": "cccccccccccccccc" + "schema_version": "2.0", + "event_id": "11111111-1111-4111-8111-111111111111", + "ts_start": "2026-05-04T10:00:00Z", + "ts_end": "2026-05-04T10:00:00.042Z", + "duration_ms": 42, + "kind": "http", + "service": "checkout", + "env": "prod", + "trace_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "span_id": "bbbbbbbbbbbbbbbb", + "status": "error", + "anchor": { + "step": "payment.charge", + "error_code": "PMT_502", + "kind": "downstream" }, - "system": {"service": "checkout-service", "env": "prod"}, - "outcome": {"success": false, "status_code": 502, "kind": "http"}, - "error": {"code": "PMT_502", "message": "payment gateway failure"}, - "metrics": {"latency_ms": 450} + "steps": [ + {"name": "cart.validate", "start_ms": 0, "duration_ms": 1, "status": "ok"}, + { + "name": "payment.charge", + "span_id": "cccccccccccccccc", + "start_ms": 7, + "duration_ms": 35, + "status": "error", + "downstream": {"service": "payment", "endpoint": "/charge", "kind": "http"}, + "error": {"code": "PMT_502", "reason": "upstream gateway 5xx"} + } + ], + "logs": [ + {"ts_offset_ms": 40, "level": "warn", "msg": "retrying payment"}, + {"ts_offset_ms": 42, "level": "error", "msg": "upstream gateway 5xx"} + ], + "fields": { + "http": {"method": "POST", "route": "/checkout", "status": 502}, + "user": {"id": "u-123", "tier": "standard", "region": "us-east-1"} + }, + "errors": [{"code": "PMT_502", "reason": "upstream gateway 5xx"}] } ``` -## 11) Schema 1.1 Additions (Optional, Back-Compat) - -Schema 1.1 introduces additive fields. All are optional; ingest still accepts 1.0 events -unchanged. OTLP-origin events leave these unset and remain valid. - -**`error` (ErrorContext) — structured triage fields** -- `error.path` — runbook URL or doc path operators can open to triage this code. -- `error.reason` — *short* operator-facing explanation of the failure (one or two sentences). - -**WideEvent (top-level)** -- `parent_request_id` — opt-in cross-trace link to a parent request (e.g. job → triggering - HTTP request). Display-only; does not affect graph indexing. -- `metadata` — free-form `map` for application-specific attributes. Display-only. -- `retry` — see below. - -**`retry` (RetryContext)** -- `retry.of` — total expected attempts for this logical operation. `0` means "unknown total". -- `retry.previous_attempt_id` — span/request ID of the previous attempt for sibling linking. - -The attempt **number** stays in `request.attempt` (existing 1.0 field). Together they read -"attempt N of M". Validation rule: `retry.of` must be `0` or `>= request.attempt`. - -### OTLP attribute mapping - -When ingesting via `/v1/otlp/v1/traces`, the following span attributes map onto 1.1 fields. -All mappings are best-effort; absent attributes leave the corresponding field unset. - -| OTLP span attribute | WideEvent field | -|----------------------------|------------------------------| -| `waylog.error.path` | `error.path` | -| `waylog.error.reason` | `error.reason` | -| `waylog.parent_request_id` | `parent_request_id` | -| `waylog.metadata.*` | `metadata[*]` (per attr) | -| `waylog.retry.of` | `retry.of` | -| `waylog.retry.previous_attempt_id` | `retry.previous_attempt_id` | -| `waylog.request.attempt` | `request.attempt` | - -OTLP events without any of these attributes still validate (the OTLP relaxed validator -already tolerates missing `user.id`). - -## 12) Implementation Checklist (Any Language) - -- [ ] Add HTTP middleware to capture trace + status + latency -- [ ] Ensure `user.id` always set (default `system`) -- [ ] Enforce event naming rules -- [ ] Populate system fields (`service`, `env`, `version`, `deployment_id`) -- [ ] Validate before emit -- [ ] Emit to Kafka topic `wide_events` -- [ ] Do not emit if no request state -- [ ] Non-blocking transport + safe shutdown - ---- +## Implementation Checklist -If you want language-specific examples (Java/Spring, Node/Express, Python/FastAPI), we can add a companion doc with minimal glue code per framework. +- Add HTTP middleware to create request context and emit exactly one event. +- Parse and propagate W3C `traceparent`. +- Populate service, env, route, status, trace, and user fields. +- Wrap meaningful business/downstream operations in steps. +- Mark the first failing operator step as `anchor`. +- Validate before emit. +- Use non-blocking delivery with safe shutdown. diff --git a/examples/cmd/e2e-emit/main.go b/examples/cmd/e2e-emit/main.go deleted file mode 100644 index 1ab8a46..0000000 --- a/examples/cmd/e2e-emit/main.go +++ /dev/null @@ -1,102 +0,0 @@ -// e2e-emit emits 3 cascading-failure traces (gateway -> checkout -> payment) -// directly to the ingest /v1/events endpoint and prints the trace_ids, one per -// line, on stdout. Used by scripts/e2e-mark2.sh to drive the Go SDK ingest -// path and exercise the rollup root-cause attribution. -// -// Each trace has 3 spans. The deepest span (payment) carries PMT_502 — the -// true root cause — while checkout and gateway carry propagated codes. With -// the RollupWindow fix, top_errors should report PMT_502=3 and not attribute -// counts to the propagated codes. -package main - -import ( - "context" - "fmt" - "os" - "time" - - "github.com/sssmaran/WaylogCLI/pkg/event" - "github.com/sssmaran/WaylogCLI/pkg/trace" - "github.com/sssmaran/WaylogCLI/pkg/transport" -) - -type span struct { - service, code, msg, reason, path string - caller, downstream string -} - -func main() { - ingestURL := "http://localhost:8080" - if v := os.Getenv("INGEST_URL"); v != "" { - ingestURL = v - } - - ht, err := transport.NewHTTPTransport(ingestURL, 5*time.Second) - if err != nil { - fmt.Fprintln(os.Stderr, "transport init:", err) - os.Exit(1) - } - defer ht.Close(context.Background()) - - // Cascade: payment (deepest, root cause) -> checkout -> gateway (root span). - chain := []span{ - {"payment", "PMT_502", "payment gateway failure", - "upstream acquirer returned 502", - "https://runbooks.example.com/payments-502", - "checkout", ""}, - {"checkout", "CHK_DOWNSTREAM", "checkout propagated failure from payment", - "downstream payment returned 502", "", - "gateway", "payment"}, - {"gateway", "GW_DOWNSTREAM", "gateway propagated failure from checkout", - "downstream checkout returned 502", "", - "", "checkout"}, - } - - for i := 0; i < 3; i++ { - traceID := trace.NewTraceID() - spanIDs := []string{trace.NewSpanID(), trace.NewSpanID(), trace.NewSpanID()} - now := time.Now().UTC() - - batch := make([]event.WideEvent, len(chain)) - for j, s := range chain { - parent := "" - if j+1 < len(spanIDs) { - parent = spanIDs[j+1] - } - batch[j] = mkEvent(now.Add(time.Duration(j)*time.Millisecond), - traceID, spanIDs[j], parent, s, int64(42+j*15)) - } - - n, err := ht.Send(context.Background(), batch) - if err != nil { - fmt.Fprintf(os.Stderr, "send trace %d (%s): sent=%d err=%v\n", i, traceID, n, err) - os.Exit(1) - } - fmt.Println(traceID) - } -} - -func mkEvent(ts time.Time, traceID, spanID, parentSpanID string, s span, latencyMs int64) event.WideEvent { - ev := event.WideEvent{ - SchemaVersion: event.SchemaVersion, - EventName: s.service + ".error", - Timestamp: ts, - User: event.UserContext{ID: "e2e-user", Tier: "standard", Region: "us-east-1"}, - Request: event.RequestContext{ - TraceID: traceID, SpanID: spanID, ParentSpanID: parentSpanID, - Flow: "purchase", HTTPMethod: "POST", FeatureFlags: []string{}, - }, - System: event.SystemContext{ - Service: s.service, Version: "0.1.0", Env: "dev", - CallerService: s.caller, DownstreamService: s.downstream, - }, - Outcome: event.OutcomeContext{Success: false, StatusCode: 502, Kind: "http"}, - Error: &event.ErrorContext{Code: s.code, Message: s.msg, Reason: s.reason, Path: s.path}, - Metrics: event.MetricsContext{LatencyMs: latencyMs}, - } - if err := ev.Validate(); err != nil { - fmt.Fprintf(os.Stderr, "invalid event for %s: %v\n", s.service, err) - os.Exit(1) - } - return ev -} diff --git a/examples/cmd/e2e-otlp/main.go b/examples/cmd/e2e-otlp/main.go index d7d9a54..33e0e90 100644 --- a/examples/cmd/e2e-otlp/main.go +++ b/examples/cmd/e2e-otlp/main.go @@ -1,6 +1,6 @@ -// e2e-otlp constructs a minimal OTLP/HTTP ExportTraceServiceRequest (1 trace, -// 2 spans) and POSTs it to the ingest server's /v1/otlp/v1/traces endpoint. -// The trace_id is printed on stdout for use by scripts/e2e-mark2.sh. +// e2e-otlp constructs a minimal OTLP/HTTP ExportTraceServiceRequest and POSTs +// it to the ingest server's /v1/otlp/v1/traces endpoint. The trace_id is +// printed on stdout for ad hoc OTLP smoke checks. package main import ( diff --git a/internal/cli/v2/cmd.go b/internal/cli/v2/cmd.go index f0256a9..f6617f1 100644 --- a/internal/cli/v2/cmd.go +++ b/internal/cli/v2/cmd.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "strconv" "strings" "time" ) @@ -219,15 +220,7 @@ func runExplain(ctx context.Context, client *Client, cfg cliConfig, args []strin } func runBlast(ctx context.Context, client *Client, cfg cliConfig, args []string, stdout, stderr io.Writer) int { - fs := newFlagSet("blast", stderr) - service := fs.String("service", "", "") - step := fs.String("step", "", "") - errorCode := fs.String("code", "", "") - window := fs.String("window", "", "") - if err := fs.Parse(args); err != nil { - return usage(stderr, "usage: waylog blast (--service --step --code | --code | ) [--window ] [--json]") - } - p, err := resolveBlastForm(BlastParams{Service: *service, Step: *step, ErrorCode: *errorCode, Window: *window}, fs.Args()) + p, err := parseBlastArgs(args) if err != nil { return usage(stderr, err.Error()) } @@ -238,6 +231,48 @@ func runBlast(ctx context.Context, client *Client, cfg cliConfig, args []string, return renderOrError(stdout, stderr, cfg.json, resp, err, RenderBlast) } +func parseBlastArgs(args []string) (BlastParams, error) { + var flags BlastParams + positionals := []string{} + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--service" || arg == "--step" || arg == "--code" || arg == "--window": + if i+1 >= len(args) { + return BlastParams{}, fmt.Errorf("%s requires a value", arg) + } + i++ + setBlastFlag(&flags, arg, args[i]) + case strings.HasPrefix(arg, "--service="): + setBlastFlag(&flags, "--service", strings.TrimPrefix(arg, "--service=")) + case strings.HasPrefix(arg, "--step="): + setBlastFlag(&flags, "--step", strings.TrimPrefix(arg, "--step=")) + case strings.HasPrefix(arg, "--code="): + setBlastFlag(&flags, "--code", strings.TrimPrefix(arg, "--code=")) + case strings.HasPrefix(arg, "--window="): + setBlastFlag(&flags, "--window", strings.TrimPrefix(arg, "--window=")) + case strings.HasPrefix(arg, "-"): + return BlastParams{}, fmt.Errorf("unknown flag: %s", arg) + default: + positionals = append(positionals, arg) + } + } + return resolveBlastForm(flags, positionals) +} + +func setBlastFlag(p *BlastParams, key, value string) { + switch key { + case "--service": + p.Service = value + case "--step": + p.Step = value + case "--code": + p.ErrorCode = value + case "--window": + p.Window = value + } +} + func resolveBlastForm(flags BlastParams, positional []string) (BlastParams, error) { if len(positional) > 1 { return BlastParams{}, errors.New("usage: waylog blast (--service --step --code | --code | ) [--window ] [--json]") @@ -262,22 +297,10 @@ func resolveBlastForm(flags BlastParams, positional []string) (BlastParams, erro } func runSearch(ctx context.Context, client *Client, cfg cliConfig, args []string, stdout, stderr io.Writer) int { - fs := newFlagSet("search", stderr) - service := fs.String("service", "", "") - status := fs.String("status", "", "") - window := fs.String("window", "", "") - limit := fs.Int("limit", 0, "") - cursor := fs.String("cursor", "", "") - errorCode := fs.String("error-code", "", "") - traceID := fs.String("trace-id", "", "") - if err := fs.Parse(args); err != nil || fs.NArg() > 1 { - return usage(stderr, "usage: waylog search [--service ] [--status ] [--window ] [--limit ] [--cursor ] [--json]") - } - query := "" - if fs.NArg() == 1 { - query = fs.Arg(0) + p, query, err := parseSearchArgs(args) + if err != nil { + return usage(stderr, err.Error()) } - p := SearchParams{Service: *service, Status: *status, Window: *window, Limit: *limit, Cursor: *cursor, ErrorCode: *errorCode, TraceID: *traceID} if p.ErrorCode == "" && p.TraceID == "" { if query == "" { return usage(stderr, "search requires , --error-code, or --trace-id") @@ -291,6 +314,79 @@ func runSearch(ctx context.Context, client *Client, cfg cliConfig, args []string return renderOrError(stdout, stderr, cfg.json, resp, err, RenderSearch) } +func parseSearchArgs(args []string) (SearchParams, string, error) { + var p SearchParams + positionals := []string{} + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--service" || arg == "--status" || arg == "--window" || arg == "--cursor" || arg == "--error-code" || arg == "--trace-id": + if i+1 >= len(args) { + return SearchParams{}, "", fmt.Errorf("%s requires a value", arg) + } + i++ + setSearchStringFlag(&p, arg, args[i]) + case arg == "--limit": + if i+1 >= len(args) { + return SearchParams{}, "", fmt.Errorf("%s requires a value", arg) + } + i++ + limit, err := strconv.Atoi(args[i]) + if err != nil { + return SearchParams{}, "", fmt.Errorf("invalid limit: %s", args[i]) + } + p.Limit = limit + case strings.HasPrefix(arg, "--service="): + setSearchStringFlag(&p, "--service", strings.TrimPrefix(arg, "--service=")) + case strings.HasPrefix(arg, "--status="): + setSearchStringFlag(&p, "--status", strings.TrimPrefix(arg, "--status=")) + case strings.HasPrefix(arg, "--window="): + setSearchStringFlag(&p, "--window", strings.TrimPrefix(arg, "--window=")) + case strings.HasPrefix(arg, "--cursor="): + setSearchStringFlag(&p, "--cursor", strings.TrimPrefix(arg, "--cursor=")) + case strings.HasPrefix(arg, "--error-code="): + setSearchStringFlag(&p, "--error-code", strings.TrimPrefix(arg, "--error-code=")) + case strings.HasPrefix(arg, "--trace-id="): + setSearchStringFlag(&p, "--trace-id", strings.TrimPrefix(arg, "--trace-id=")) + case strings.HasPrefix(arg, "--limit="): + limit, err := strconv.Atoi(strings.TrimPrefix(arg, "--limit=")) + if err != nil { + return SearchParams{}, "", fmt.Errorf("invalid limit: %s", strings.TrimPrefix(arg, "--limit=")) + } + p.Limit = limit + case strings.HasPrefix(arg, "-"): + return SearchParams{}, "", fmt.Errorf("unknown flag: %s", arg) + default: + positionals = append(positionals, arg) + } + } + if len(positionals) > 1 { + return SearchParams{}, "", errors.New("usage: waylog search [--service ] [--status ] [--window ] [--limit ] [--cursor ] [--json]") + } + query := "" + if len(positionals) == 1 { + query = positionals[0] + } + return p, query, nil +} + +func setSearchStringFlag(p *SearchParams, key, value string) { + switch key { + case "--service": + p.Service = value + case "--status": + p.Status = value + case "--window": + p.Window = value + case "--cursor": + p.Cursor = value + case "--error-code": + p.ErrorCode = value + case "--trace-id": + p.TraceID = value + } +} + func newFlagSet(name string, stderr io.Writer) *flag.FlagSet { fs := flag.NewFlagSet(name, flag.ContinueOnError) fs.SetOutput(stderr) diff --git a/internal/cli/v2/cmd_test.go b/internal/cli/v2/cmd_test.go index e813010..3f038f8 100644 --- a/internal/cli/v2/cmd_test.go +++ b/internal/cli/v2/cmd_test.go @@ -149,6 +149,57 @@ func TestRunCLIExplainFallsBackToTraceID(t *testing.T) { } } +func TestRunCLIBlastDisplayFamilyAllowsTrailingWindow(t *testing.T) { + var gotPath, gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/capabilities" { + _, _ = w.Write([]byte(`{"v2_reads":{"enabled":true}}`)) + return + } + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + _, _ = w.Write([]byte(`{"key":{"service":"checkout","step":"payment.charge","error_code":"PMT_502"},"view_mode":"single_family","window":"15m","affected_requests":1,"affected_services":2,"top_services":["checkout","payment"],"sample_traces":["trace"]}`)) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := RunCLI([]string{"--addr", srv.URL, "blast", "checkout:payment.charge:PMT_502", "--window", "15m"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if gotPath != "/v1/blast_radius" || !strings.Contains(gotQuery, "error_family=checkout%3Apayment.charge%3APMT_502") || !strings.Contains(gotQuery, "window=15m") { + t.Fatalf("path=%q query=%q", gotPath, gotQuery) + } +} + +func TestRunCLISearchAllowsTrailingFilters(t *testing.T) { + var gotPath, gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/capabilities" { + _, _ = w.Write([]byte(`{"v2_reads":{"enabled":true}}`)) + return + } + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + _, _ = w.Write([]byte(`{"events":[],"next_cursor":null}`)) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := RunCLI([]string{"--addr", srv.URL, "search", "PMT_502", "--window", "15m", "--limit", "5"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + for _, want := range []string{"error_code=PMT_502", "window=15m", "limit=5"} { + if !strings.Contains(gotQuery, want) { + t.Fatalf("query=%q missing %q", gotQuery, want) + } + } + if gotPath != "/v1/events/search" { + t.Fatalf("path=%q", gotPath) + } +} + func TestRunCLIUsage(t *testing.T) { var stdout, stderr bytes.Buffer code := RunCLI([]string{"search"}, nil, &stdout, &stderr) diff --git a/internal/ingest/v2/handler_test.go b/internal/ingest/v2/handler_test.go index c6d3552..733868c 100644 --- a/internal/ingest/v2/handler_test.go +++ b/internal/ingest/v2/handler_test.go @@ -145,6 +145,49 @@ func TestEventsNDJSONBatches(t *testing.T) { } } +func TestEventsMixedNDJSONDurabilityAndSuppressedReadInvariants(t *testing.T) { + index := NewRecentIndex(nil) + h, wal := newTestHandlerWithIndex(t, nil, index) + invalid := validEventMap("00000000-0000-4000-8000-000000000202") + delete(invalid, "service") + body := strings.Join([]string{ + mustJSON(t, cascadeCheckoutFailureEvent()), + mustJSON(t, invalid), + mustJSON(t, suppressedPaymentEvent()), + }, "\n") + + env := expectEnvelopeStatus(t, post(t, h, "application/x-ndjson", "", body), http.StatusOK) + if env.Accepted != 2 || env.Duplicate != 0 || len(env.Rejected) != 1 { + t.Fatalf("env=%+v", env) + } + if env.Rejected[0].Index != 1 || env.Rejected[0].Reason != ReasonSchemaValidationFailed { + t.Fatalf("rejected=%+v", env.Rejected[0]) + } + if wal.Count() != 2 { + t.Fatalf("wal writes=%d want 2", wal.Count()) + } + + reader := NewReader(index) + filter := SearchFilter{Since: testTime(-1), Until: testTime(10)} + if _, ok := reader.GetEvent("00000000-0000-4000-8000-000000000202"); ok { + t.Fatal("invalid event should not be readable") + } + if got := reader.Errors(filter, nil, 10); len(got.Rows) != 1 || got.Rows[0].ErrorFamily.ErrorCode != "PMT_502" { + t.Fatalf("errors=%+v", got) + } + blast := reader.BlastRadius(filter, BlastKeyMode{Key: BlastKey{ErrorCode: "PMT_502"}, CrossCode: true}) + if blast.AffectedRequests != 1 { + t.Fatalf("blast=%+v want one unsuppressed request", blast) + } + if got := ids(reader.SearchEvents(filter, nil, 10).Events); got != "00000000-0000-4000-8000-000000000102" { + t.Fatalf("default search=%s", got) + } + filter.IncludeSuppressed = true + if got := ids(reader.SearchEvents(filter, nil, 10).Events); got != "00000000-0000-4000-8000-000000000104,00000000-0000-4000-8000-000000000102" { + t.Fatalf("include suppressed search=%s", got) + } +} + func TestEventsBatchOversize(t *testing.T) { var b strings.Builder for i := 0; i < maxBatchItems+1; i++ { diff --git a/internal/ingest/v2/projector_test.go b/internal/ingest/v2/projector_test.go index 8cce9c8..041092d 100644 --- a/internal/ingest/v2/projector_test.go +++ b/internal/ingest/v2/projector_test.go @@ -121,6 +121,51 @@ func TestRecentIndexPrunePreservesTraceOrder(t *testing.T) { } } +func TestRecentIndexPruneRemovesOldReadsAndKeepsCursorOrder(t *testing.T) { + idx := NewRecentIndex(nil) + base := time.Date(2026, 4, 25, 14, 0, 0, 0, time.UTC) + oldErr := testEvent("old-error", eventv2.StatusError) + oldErr.TraceID = "trace-old" + oldErr.TsStart = base + oldErr.TsEnd = base.Add(10 * time.Millisecond) + oldErr.Anchor = &eventv2.Anchor{Step: "payment.charge", ErrorCode: "PMT_502"} + freshA := testEvent("fresh-a", eventv2.StatusOK) + freshA.TraceID = "trace-a" + freshA.TsStart = base.Add(1 * time.Hour) + freshA.TsEnd = freshA.TsStart.Add(10 * time.Millisecond) + freshB := testEvent("fresh-b", eventv2.StatusError) + freshB.TraceID = "trace-b" + freshB.TsStart = base.Add(2 * time.Hour) + freshB.TsEnd = freshB.TsStart.Add(10 * time.Millisecond) + freshB.Anchor = &eventv2.Anchor{Step: "db.load_cart", ErrorCode: "CART_NOT_FOUND"} + + projector := NewProjector(idx) + projector.Project(oldErr) + projector.Project(freshA) + projector.Project(freshB) + + pruned := idx.PruneOlderThan(base.Add(30 * time.Minute)) + if pruned.Events != 1 { + t.Fatalf("pruned=%d want 1", pruned.Events) + } + reader := NewReader(idx) + filter := SearchFilter{Since: base, Until: base.Add(3 * time.Hour)} + if _, ok := reader.GetEvent("old-error"); ok { + t.Fatal("old event should be gone") + } + if got := reader.Errors(filter, nil, 10); len(got.Rows) != 1 || got.Rows[0].ErrorFamily.ErrorCode != "CART_NOT_FOUND" { + t.Fatalf("errors=%+v", got) + } + page1 := reader.SearchEvents(filter, nil, 1) + if got := ids(page1.Events); got != "fresh-b" || page1.NextCursor == nil { + t.Fatalf("page1=%s cursor=%+v", got, page1.NextCursor) + } + page2 := reader.SearchEvents(filter, page1.NextCursor, 1) + if got := ids(page2.Events); got != "fresh-a" || page2.NextCursor != nil { + t.Fatalf("page2=%s cursor=%+v", got, page2.NextCursor) + } +} + func eventIDs(events []*eventv2.Event) []string { out := make([]string, 0, len(events)) for _, ev := range events { diff --git a/internal/ingest/v2/replay_test.go b/internal/ingest/v2/replay_test.go index ba35741..5eb1c3f 100644 --- a/internal/ingest/v2/replay_test.go +++ b/internal/ingest/v2/replay_test.go @@ -1,14 +1,19 @@ package ingestv2 import ( + "context" + "encoding/json" "os" "path/filepath" + "reflect" "strings" "testing" "time" "github.com/prometheus/client_golang/prometheus" + eventlogv2 "github.com/sssmaran/WaylogCLI/internal/eventlog/v2" "github.com/sssmaran/WaylogCLI/internal/metrics" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" ) func TestReplayWALRebuildsDedupAndIndex(t *testing.T) { @@ -118,6 +123,55 @@ func TestReplayWALPreservesNewestAtDedupCapacity(t *testing.T) { } } +func TestReplayWALRebuildsEquivalentReadAPIsAndDedupe(t *testing.T) { + dir := t.TempDir() + wal, err := eventlogv2.New(dir, eventlogv2.WithSync(false)) + if err != nil { + t.Fatalf("eventlogv2.New: %v", err) + } + liveDedup := NewDedup(32, nil) + liveIndex := NewRecentIndex(nil) + liveHandler := newTestHandlerWithConfig(t, Config{Dedup: liveDedup, WAL: wal, Index: liveIndex}) + + bodies := []string{ + mustJSON(t, cascadeGatewayEvent()), + mustJSON(t, cascadeCheckoutFailureEvent()), + mustJSON(t, happyCheckoutEvent()), + mustJSON(t, suppressedPaymentEvent()), + } + env, err := liveHandler.IngestRaw(context.Background(), byteBodies(bodies), true) + if err != nil { + t.Fatalf("IngestRaw: %v", err) + } + if env.Accepted != len(bodies) || env.Duplicate != 0 || len(env.Rejected) != 0 { + t.Fatalf("env=%+v", env) + } + if err := wal.Close(); err != nil { + t.Fatalf("close WAL: %v", err) + } + + replayDedup := NewDedup(32, nil) + replayIndex := NewRecentIndex(nil) + res, err := ReplayWAL(dir, replayDedup, NewProjector(replayIndex), time.Time{}, nil) + if err != nil { + t.Fatalf("ReplayWAL: %v", err) + } + if res.Projected != len(bodies) || res.DedupLoaded != len(bodies) { + t.Fatalf("replay=%+v", res) + } + + assertReaderEquivalent(t, NewReader(liveIndex), NewReader(replayIndex)) + + replayHandler := newTestHandlerWithConfig(t, Config{Dedup: replayDedup, WAL: &fakeWAL{}, Index: replayIndex}) + dupEnv, err := replayHandler.IngestRaw(context.Background(), [][]byte{[]byte(mustJSON(t, cascadeCheckoutFailureEvent()))}, true) + if err != nil { + t.Fatalf("duplicate IngestRaw: %v", err) + } + if dupEnv.Accepted != 0 || dupEnv.Duplicate != 1 || len(dupEnv.Rejected) != 0 { + t.Fatalf("duplicate env=%+v", dupEnv) + } +} + func writeV2ReplayFile(t *testing.T, dir, name string, modTime time.Time, lines []string) { t.Helper() body := strings.Join(lines, "\n") + "\n" @@ -129,3 +183,158 @@ func writeV2ReplayFile(t *testing.T, dir, name string, modTime time.Time, lines t.Fatal(err) } } + +func assertReaderEquivalent(t *testing.T, live, replay *Reader) { + t.Helper() + filter := SearchFilter{Since: testTime(-1), Until: testTime(20)} + blastKey := BlastKeyMode{Key: BlastKey{Service: "checkout", Step: "payment.charge", ErrorCode: "PMT_502"}} + + checks := []struct { + name string + live any + replay any + }{ + {"recent", live.RecentTraces(filter, nil, 10), replay.RecentTraces(filter, nil, 10)}, + {"errors", live.Errors(filter, nil, 10), replay.Errors(filter, nil, 10)}, + {"story", mustStory(t, live, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), mustStory(t, replay, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")}, + {"blast", live.BlastRadius(filter, blastKey), replay.BlastRadius(filter, blastKey)}, + {"event", mustEvent(t, live, "00000000-0000-4000-8000-000000000102"), mustEvent(t, replay, "00000000-0000-4000-8000-000000000102")}, + {"trace", mustTrace(t, live, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), mustTrace(t, replay, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")}, + } + for _, check := range checks { + if !reflect.DeepEqual(check.live, check.replay) { + t.Fatalf("%s mismatch\nlive=%s\nreplay=%s", check.name, stableJSON(t, check.live), stableJSON(t, check.replay)) + } + } +} + +func mustStory(t *testing.T, r *Reader, traceID string) StoryResponse { + t.Helper() + story, ok := r.TraceStoryByTraceID(traceID) + if !ok { + t.Fatalf("story %s not found", traceID) + } + return story +} + +func mustEvent(t *testing.T, r *Reader, eventID string) *eventv2.Event { + t.Helper() + ev, ok := r.GetEvent(eventID) + if !ok { + t.Fatalf("event %s not found", eventID) + } + return ev +} + +func mustTrace(t *testing.T, r *Reader, traceID string) TraceGetResult { + t.Helper() + trace, ok := r.GetTrace(traceID) + if !ok { + t.Fatalf("trace %s not found", traceID) + } + return trace +} + +func stableJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func byteBodies(lines []string) [][]byte { + out := make([][]byte, 0, len(lines)) + for _, line := range lines { + out = append(out, []byte(line)) + } + return out +} + +func cascadeGatewayEvent() map[string]any { + raw := validEventMap("00000000-0000-4000-8000-000000000101") + raw["trace_id"] = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + raw["service"] = "api-gateway" + raw["span_id"] = "1111111111111111" + raw["status"] = "ok" + raw["ts_start"] = testTime(1).Format(time.RFC3339Nano) + raw["ts_end"] = testTime(1).Add(90 * time.Millisecond).Format(time.RFC3339Nano) + raw["duration_ms"] = 90 + raw["steps"] = []any{ + map[string]any{ + "name": "checkout.purchase", + "span_id": "2222222222222222", + "start_ms": 2, + "duration_ms": 80, + "status": "ok", + "downstream": map[string]any{"service": "checkout", "endpoint": "/checkout", "kind": "http"}, + }, + } + raw["fields"] = map[string]any{ + "http": map[string]any{"method": "POST", "route": "/purchase", "status": 502}, + "user": map[string]any{"id": "u-001"}, + } + return raw +} + +func cascadeCheckoutFailureEvent() map[string]any { + raw := validEventMap("00000000-0000-4000-8000-000000000102") + raw["trace_id"] = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + raw["service"] = "checkout" + raw["span_id"] = "3333333333333333" + raw["parent_span_id"] = "2222222222222222" + raw["status"] = "error" + raw["ts_start"] = testTime(2).Format(time.RFC3339Nano) + raw["ts_end"] = testTime(2).Add(60 * time.Millisecond).Format(time.RFC3339Nano) + raw["duration_ms"] = 60 + raw["anchor"] = map[string]any{"step": "payment.charge", "error_code": "PMT_502", "kind": "downstream"} + raw["steps"] = []any{ + map[string]any{"name": "cart.validate", "start_ms": 0, "duration_ms": 1, "status": "ok"}, + map[string]any{"name": "db.load_cart", "span_id": "4444444444444444", "start_ms": 1, "duration_ms": 4, "status": "ok", "downstream": map[string]any{"service": "db", "endpoint": "/cart/X1", "kind": "http"}}, + map[string]any{"name": "inventory.reserve", "start_ms": 5, "duration_ms": 2, "status": "ok"}, + map[string]any{"name": "payment.charge", "span_id": "5555555555555555", "start_ms": 7, "duration_ms": 35, "status": "error", "downstream": map[string]any{"service": "payment", "endpoint": "/charge", "kind": "http"}, "error": map[string]any{"code": "PMT_502", "reason": "upstream gateway 5xx"}}, + } + raw["logs"] = []any{ + map[string]any{"ts_offset_ms": 5, "level": "info", "msg": "inventory reserved"}, + map[string]any{"ts_offset_ms": 40, "level": "warn", "msg": "retrying payment"}, + map[string]any{"ts_offset_ms": 42, "level": "error", "msg": "upstream gateway 5xx"}, + } + raw["fields"] = map[string]any{ + "http": map[string]any{"method": "POST", "route": "/checkout", "status": 502}, + "user": map[string]any{"id": "u-001"}, + } + raw["errors"] = []any{map[string]any{"code": "PMT_502", "reason": "upstream gateway 5xx"}} + return raw +} + +func happyCheckoutEvent() map[string]any { + raw := validEventMap("00000000-0000-4000-8000-000000000103") + raw["trace_id"] = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + raw["service"] = "checkout" + raw["ts_start"] = testTime(3).Format(time.RFC3339Nano) + raw["ts_end"] = testTime(3).Add(20 * time.Millisecond).Format(time.RFC3339Nano) + raw["duration_ms"] = 20 + raw["fields"] = map[string]any{ + "http": map[string]any{"method": "POST", "route": "/checkout", "status": 200}, + "user": map[string]any{"id": "u-002"}, + } + return raw +} + +func suppressedPaymentEvent() map[string]any { + raw := validEventMap("00000000-0000-4000-8000-000000000104") + raw["trace_id"] = "cccccccccccccccccccccccccccccccc" + raw["service"] = "payment" + raw["status"] = "suppressed" + raw["ts_start"] = testTime(4).Format(time.RFC3339Nano) + raw["ts_end"] = testTime(4).Add(10 * time.Millisecond).Format(time.RFC3339Nano) + raw["duration_ms"] = 10 + raw["steps"] = []any{} + raw["logs"] = []any{} + raw["fields"] = map[string]any{ + "http": map[string]any{"method": "POST", "route": "/charge", "status": 502}, + "user": map[string]any{"id": "u-003"}, + } + return raw +} diff --git a/scripts/demo-acceptance-json/main.go b/scripts/demo-acceptance-json/main.go new file mode 100644 index 0000000..4996ede --- /dev/null +++ b/scripts/demo-acceptance-json/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" +) + +type errorsResponse struct { + Rows []errorRow `json:"rows"` +} + +type errorRow struct { + ErrorFamily errorFamily `json:"error_family"` + AffectedTraces int `json:"affected_traces"` + SampleTraces []string `json:"sample_traces"` +} + +type errorFamily struct { + Service string `json:"service"` + Step string `json:"step"` + ErrorCode string `json:"error_code"` +} + +type searchResponse struct { + Events []searchEvent `json:"events"` +} + +type searchEvent struct { + EventID string `json:"event_id"` +} + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: demo-acceptance-json ") + os.Exit(2) + } + + body, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintf(os.Stderr, "read stdin: %v\n", err) + os.Exit(2) + } + + switch os.Args[1] { + case "has-payment-error": + if !hasPaymentError(body) { + os.Exit(1) + } + case "first-payment-trace": + fmt.Println(firstPaymentTrace(body)) + case "first-event-id": + fmt.Println(firstEventID(body)) + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) + os.Exit(2) + } +} + +func hasPaymentError(body []byte) bool { + var resp errorsResponse + if err := json.Unmarshal(body, &resp); err != nil { + return false + } + for _, row := range resp.Rows { + if isPayment502(row) && row.AffectedTraces > 0 { + return true + } + } + return false +} + +func firstPaymentTrace(body []byte) string { + var resp errorsResponse + if err := json.Unmarshal(body, &resp); err != nil { + return "" + } + for _, row := range resp.Rows { + if isPayment502(row) && len(row.SampleTraces) > 0 { + return row.SampleTraces[0] + } + } + return "" +} + +func firstEventID(body []byte) string { + var resp searchResponse + if err := json.Unmarshal(body, &resp); err != nil { + return "" + } + if len(resp.Events) == 0 { + return "" + } + return resp.Events[0].EventID +} + +func isPayment502(row errorRow) bool { + return row.ErrorFamily.Service == "checkout" && + row.ErrorFamily.Step == "payment.charge" && + row.ErrorFamily.ErrorCode == "PMT_502" +} diff --git a/scripts/demo-acceptance.sh b/scripts/demo-acceptance.sh new file mode 100755 index 0000000..d9fa462 --- /dev/null +++ b/scripts/demo-acceptance.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +GATEWAY_URL="${GATEWAY_URL:-http://localhost:9081}" +INGEST_URL="${INGEST_URL:-http://localhost:8080}" +WAYLOG_READ_KEY="${WAYLOG_READ_KEY:-demo}" +REQUESTS="${REQUESTS:-20}" +CONCURRENCY="${CONCURRENCY:-5}" +TIMEOUT="${WAYLOG_CLI_TIMEOUT:-5s}" +CLI_BIN="${WAYLOG_CLI_BIN:-}" +JSON_BIN="${WAYLOG_JSON_HELPER_BIN:-}" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +http_code() { + curl -s -o /dev/null -w "%{http_code}" "$1" || echo "000" +} + +json_has_payment_error() { + "$JSON_BIN" has-payment-error +} + +json_first_payment_trace() { + "$JSON_BIN" first-payment-trace +} + +json_first_event_id() { + "$JSON_BIN" first-event-id +} + +if [[ "$(http_code "${GATEWAY_URL}/demo")" != "200" ]] || [[ "$(http_code "${INGEST_URL}/healthz")" != "200" ]]; then + fail "demo stack is not running. Start it with: make demo" +fi +echo "PASS: demo stack is reachable" + +if [[ -z "$CLI_BIN" ]]; then + mkdir -p ./data/demo-state/bin + CLI_BIN="./data/demo-state/bin/waylog" + go build -o "$CLI_BIN" ./cmd/waylog +elif [[ ! -x "$CLI_BIN" ]]; then + fail "WAYLOG_CLI_BIN is not executable: $CLI_BIN" +fi + +if [[ -z "$JSON_BIN" ]]; then + mkdir -p ./data/demo-state/bin + JSON_BIN="./data/demo-state/bin/demo-acceptance-json" + go build -o "$JSON_BIN" ./scripts/demo-acceptance-json +elif [[ ! -x "$JSON_BIN" ]]; then + fail "WAYLOG_JSON_HELPER_BIN is not executable: $JSON_BIN" +fi + +CLI=("$CLI_BIN" --addr "$INGEST_URL" --api-key "$WAYLOG_READ_KEY" --timeout "$TIMEOUT") + +"${CLI[@]}" --json capabilities >/dev/null || fail "waylog capabilities failed" +echo "PASS: waylog capabilities" + +burst_body="{\"requests\":${REQUESTS},\"concurrency\":${CONCURRENCY}}" +burst_status="$(curl -s -o /tmp/waylog-demo-burst.json -w "%{http_code}" \ + -X POST "${GATEWAY_URL}/demo/burst" \ + -H 'Content-Type: application/json' \ + --data "$burst_body" || echo "000")" +[[ "$burst_status" == "200" ]] || fail "traffic burst failed: HTTP $burst_status" +echo "PASS: traffic burst captured (${REQUESTS} requests / ${CONCURRENCY} concurrency)" + +errors_json="" +for _ in $(seq 1 12); do + errors_json="$("${CLI[@]}" --json errors --window 15m --limit 10)" || fail "waylog errors failed" + if json_has_payment_error <<<"$errors_json"; then + break + fi + sleep 1 +done +json_has_payment_error <<<"$errors_json" || fail "payment_502 error family did not appear in /v1/errors" +echo "PASS: waylog errors contains checkout:payment.charge:PMT_502" + +"${CLI[@]}" --json recent --limit 5 >/dev/null || fail "waylog recent failed" +echo "PASS: waylog recent" + +"${CLI[@]}" --json blast checkout:payment.charge:PMT_502 --window 15m >/dev/null || fail "waylog blast failed" +echo "PASS: waylog blast" + +trace_id="$(json_first_payment_trace <<<"$errors_json")" +[[ -n "$trace_id" ]] || fail "no sample trace_id found for payment_502" + +"${CLI[@]}" --json explain "$trace_id" >/dev/null || fail "waylog explain failed for trace $trace_id" +echo "PASS: waylog explain" + +search_json="$("${CLI[@]}" --json search PMT_502 --window 15m --limit 5)" || fail "waylog search failed" +event_id="$(json_first_event_id <<<"$search_json")" +[[ -n "$event_id" ]] || fail "no sample event_id found for PMT_502" + +"${CLI[@]}" --json event "$event_id" >/dev/null || fail "waylog event failed for event $event_id" +echo "PASS: waylog event" + +echo "Demo acceptance passed." diff --git a/scripts/demo.sh b/scripts/demo.sh index d913e21..aab621d 100755 --- a/scripts/demo.sh +++ b/scripts/demo.sh @@ -141,11 +141,13 @@ Open: Dashboard: http://localhost:8080/ui/ How to demo it: - 1. Open Demo controls and click "Run payment outage". - 2. Open Dashboard and watch the failure appear. - 3. Click the failure family to inspect impact, then a trace to explain it. + 1. Open Demo controls and click "Run traffic burst". + 2. Open Dashboard and inspect errors, impact, and trace explanation. + 3. Or run: make demo-acceptance Useful CLI checks: + ./waylog capabilities + ./waylog recent --limit 5 ./waylog errors --window 15m ./waylog blast --service checkout --step payment.charge --code PMT_502 --window 15m diff --git a/scripts/e2e-mark2.sh b/scripts/e2e-mark2.sh deleted file mode 100644 index 35dbd2a..0000000 --- a/scripts/e2e-mark2.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env bash -# End-to-end validation for Waylog Mark II v1 (Milestone 6 Task 4). -# Starts a fresh ingest server, drives three ingestion paths (Go SDK, TS SDK, -# synthetic OTLP/HTTP), then asserts tree-shaped trace story per source and -# root-cause-counted rollup (top_errors[PMT_502] == 3). -set -euo pipefail - -REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -# shellcheck source=scripts/_lib.sh -source "${REPO_ROOT}/scripts/_lib.sh" - -PORT="${E2E_INGEST_PORT:-18080}" -BASE_URL="http://localhost:${PORT}" -export BASE_URL - -WORK_DIR="" -INGEST_PID="" -cleanup() { - if [[ -n "$INGEST_PID" ]] && kill -0 "$INGEST_PID" 2>/dev/null; then - kill "$INGEST_PID" 2>/dev/null || true - wait "$INGEST_PID" 2>/dev/null || true - fi - [[ -n "$WORK_DIR" && -d "$WORK_DIR" ]] && rm -rf "$WORK_DIR" -} -trap cleanup EXIT - -echo "=== Waylog Mark II v1 E2E ===" - -for bin in go node curl; do - command -v "$bin" >/dev/null || { echo "ERROR: missing binary: $bin"; exit 1; } -done -node_major=$(node -v | sed 's/^v//' | cut -d. -f1) -[[ "$node_major" -lt 18 ]] && { echo "ERROR: node >= 18 required, got $(node -v)"; exit 1; } -command -v jq >/dev/null || echo "WARN: jq not found — rollup assertions require jq" - -VITE_NODE="${REPO_ROOT}/packages/waylog-ts/node_modules/.bin/vite-node" -[[ -x "$VITE_NODE" ]] || { echo "ERROR: vite-node missing at $VITE_NODE (run 'npm install' in packages/waylog-ts)"; exit 1; } - -BIN_DIR="${REPO_ROOT}/bin" -mkdir -p "$BIN_DIR" -echo "Building binaries ..." -go build -o "${BIN_DIR}/e2e-emit" "${REPO_ROOT}/examples/cmd/e2e-emit" -go build -o "${BIN_DIR}/e2e-otlp" "${REPO_ROOT}/examples/cmd/e2e-otlp" -go build -o "${BIN_DIR}/ingest" "${REPO_ROOT}/cmd/ingest" - -WORK_DIR=$(mktemp -d -t waylog-e2e-XXXXXX) -echo "Data dir: $WORK_DIR" -INGEST_ADDR=":${PORT}" \ -SNAPSHOT_PATH="${WORK_DIR}/snapshot.json" \ -EVENT_LOG_DIR="${WORK_DIR}/wal" \ -EVENT_LOG_SYNC=false \ -HAPPY_SAMPLE_RATE_PCT=100 \ -GRAPH_UI=1 \ -"${BIN_DIR}/ingest" "${WORK_DIR}/ingest.log" 2>&1 & -INGEST_PID=$! -echo "Ingest PID: $INGEST_PID" -wait_ready - -echo "" -echo "--- Go SDK: cascading failure (3 traces) ---" -GO_OUT="${WORK_DIR}/go-traces.txt" -INGEST_URL="$BASE_URL" "${BIN_DIR}/e2e-emit" >"$GO_OUT" -GO_TRACE_IDS=() -while IFS= read -r line; do - [[ -n "$line" ]] && GO_TRACE_IDS[${#GO_TRACE_IDS[@]}]="$line" -done <"$GO_OUT" -for tid in "${GO_TRACE_IDS[@]}"; do echo " trace_id=$tid"; done -[[ "${#GO_TRACE_IDS[@]}" -eq 3 ]] || { echo "ERROR: expected 3 Go trace_ids, got ${#GO_TRACE_IDS[@]}"; exit 1; } - -echo "" -echo "--- TS SDK: single trace ---" -TS_TRACE_ID=$(cd "${REPO_ROOT}/packages/waylog-ts" && INGEST_URL="$BASE_URL" "$VITE_NODE" examples/e2e-emit.ts 2>/dev/null | tail -1) -echo " trace_id=$TS_TRACE_ID" -[[ "${#TS_TRACE_ID}" -eq 32 ]] || { echo "ERROR: TS SDK invalid trace_id: '$TS_TRACE_ID'"; exit 1; } - -echo "" -echo "--- OTLP: synthetic 2-span trace ---" -OTLP_TRACE_ID=$(INGEST_URL="$BASE_URL" "${BIN_DIR}/e2e-otlp") -echo " trace_id=$OTLP_TRACE_ID" -[[ "${#OTLP_TRACE_ID}" -eq 32 ]] || { echo "ERROR: OTLP invalid trace_id: '$OTLP_TRACE_ID'"; exit 1; } - -# Drain WAL + counter refresh. -sleep 2 - -echo "" -echo "--- /v1/traces/story?format=tree per source ---" -assert_tree() { - local label="$1" tid="$2" - local body status - body=$(curl -s -w $'\n%{http_code}' "${BASE_URL}/v1/traces/story?trace_id=${tid}&format=tree") - status=$(echo "$body" | tail -n1) - body=$(echo "$body" | sed '$d') - if [[ "$status" != "200" ]]; then - echo " FAIL: $label story fetch (HTTP $status)" - _failed=$((_failed + 1)); return - fi - if ! command -v jq >/dev/null; then - echo " PASS: $label story fetch (HTTP 200, jq not available)" - _passed=$((_passed + 1)); return - fi - local chain_len tree_len - chain_len=$(echo "$body" | jq '(.story.chain // []) | length') - tree_len=$(echo "$body" | jq '(.story.tree // []) | length') - if [[ "$chain_len" -gt 0 && "$tree_len" -gt 0 ]]; then - echo " PASS: $label story tree (chain=$chain_len tree=$tree_len)" - _passed=$((_passed + 1)) - else - echo " FAIL: $label story tree (chain=$chain_len tree=$tree_len)" - _failed=$((_failed + 1)) - fi -} -for tid in "${GO_TRACE_IDS[@]}"; do assert_tree "go[${tid:0:8}]" "$tid"; done -assert_tree "ts[${TS_TRACE_ID:0:8}]" "$TS_TRACE_ID" -assert_tree "otlp[${OTLP_TRACE_ID:0:8}]" "$OTLP_TRACE_ID" - -echo "" -echo "--- Rollup: top_errors must be root-cause-counted ---" -INSIGHTS=$(curl -s "${BASE_URL}/v1/tools/graph_insights" -H 'Content-Type: application/json' -d '{"window":"10m","top_errors":10}') -if command -v jq >/dev/null; then - count_for() { - echo "$INSIGHTS" | jq -r --arg c "$1" '((.data // .).top_errors // .top_errors // []) | map(select(.error_code==$c)) | .[0].count // 0' - } - PMT=$(count_for PMT_502) - CHK=$(count_for CHK_DOWNSTREAM) - GW=$(count_for GW_DOWNSTREAM) - TOTAL=$(echo "$INSIGHTS" | jq -r '(.data // .).total_failures // .total_failures // 0') - assert_eq() { - local desc="$1" got="$2" want="$3" - if [[ "$got" == "$want" ]]; then - echo " PASS: $desc (got $got)"; _passed=$((_passed + 1)) - else - echo " FAIL: $desc (got $got, want $want)" - echo " raw insights: $INSIGHTS" - _failed=$((_failed + 1)) - fi - } - assert_eq "top_errors[PMT_502] == 3 (root-cause-counted)" "$PMT" "3" - assert_eq "top_errors[CHK_DOWNSTREAM] == 0 (propagated code excluded)" "$CHK" "0" - assert_eq "top_errors[GW_DOWNSTREAM] == 0 (propagated code excluded)" "$GW" "0" - assert_eq "total_failures == 3" "$TOTAL" "3" -else - echo " SKIP: jq required for rollup assertions. Raw: $INSIGHTS" -fi - -echo "" -print_results