diff --git a/Makefile b/Makefile index 1db8e11..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 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:" @@ -16,9 +16,12 @@ 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 " micro-demo - start 4-service micro-demo (gateway+checkout+db+payment)" + @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" @echo " micro-demo-stop - stop micro-demo processes" @echo " waylog-live - run TUI dashboard (connects to ingest server)" @echo " docker-build - build all Docker images" @@ -112,13 +115,20 @@ 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 +demo-acceptance: + ./scripts/demo-acceptance.sh + +demo-up: docker-dev + +demo-down: docker-down + micro-demo: - START_KAFKA=1 ./scripts/micro-demo.sh + ./scripts/micro-demo.sh micro-demo-stop: ./scripts/micro-demo-stop.sh @@ -137,6 +147,10 @@ docker-reset: docker-dev: ENV_FILE=deploy/dev.env docker compose up -d --build + @echo "v2 demo stack running:" + @echo " Dashboard: http://localhost:8080/ui/ (key: demo)" + @echo " Demo UI: http://localhost:9081/demo" + @echo " Trigger: curl -s -X POST http://localhost:9081/purchase -H 'Content-Type: application/json' --data '{\"sku\":\"X1\",\"scenario\":\"payment_502\"}'" docker-prod: ENV_FILE=deploy/prod.env docker compose up -d --build diff --git a/README.md b/README.md index bce4062..439170b 100644 --- a/README.md +++ b/README.md @@ -33,17 +33,49 @@ A request hits your API gateway, fans out to three services, and one of them fai This is not log search. Waylog builds a live in-memory graph from every request flowing through your services. When you ask a question — "why did this trace fail?", "who is affected by `PMT_502`?", "what changed in the last 10 minutes?" — it walks the graph and returns a precomputed, structured answer. Root-cause rollups count the originating failure once, not once per propagated hop. -Run `make docker-dev` and see it yourself. +Run `make demo` and see it yourself. + +## Quick start + +```bash +make demo +``` + +This starts the ingest server plus four real Go demo services wired through the schema-2.0 Go SDK (`api-gateway → checkout → db/payment`), enables `WAYLOG_V2_READS=true`, and does not require Docker, Kafka, or the bridge process. + +Once the stack is up: + +1. Open demo controls at , or open the dashboard at . The local demo disables dashboard login. +2. Click **Run traffic burst** to fire a production-like mix through the checkout chain. For a focused single-trace look, click **Run payment outage** instead, 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 errors --window 15m + ./waylog explain + ./waylog blast --service checkout --step payment.charge --code PMT_502 --window 15m + ./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 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`. + ## 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 @@ -54,12 +86,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" }); @@ -97,29 +131,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. - -## Quick start - -```bash -make docker-dev -``` - -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. - -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`. - -> `./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. +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) @@ -134,13 +146,20 @@ 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 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 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. 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) ```bash @@ -166,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) @@ -204,42 +223,37 @@ Full schemas: `GET /v1/tools` or [`docs/openapi.yaml`](docs/openapi.yaml). ## Dashboard -The embedded dashboard at `/ui` is the fastest way to see a running system: +The embedded dashboard at `/ui` is a v2 triage surface over the same read APIs as the CLI. It requires `WAYLOG_V2_READS=true` and uses the dashboard session cookie for read-scope auth. -- Geist dark theme with a light-mode toggle -- failing-traces banner at the top of the bento layout -- KPI overview with time series (error rate, p50/p95/p99) -- recent traces with flat-chain **and** tree views in the trace modal -- **most likely failure origin** — root-cause attribution per window (rollup-correct) -- **who's affected** — user cohort by tier and region -- **what changed** — deploy-diff panel with causal shadow-mode claims -- graph topology (Cytoscape, cose layout) — gated by `GRAPH_UI=1` -- SSE live updates, no polling +- dark, minimal Geist UI with aligned KPI modules and inline SVG mini-graphs +- `#/errors` — top error families over `/v1/errors` +- `#/explain/` — first observable failing step over `/v1/traces/story` +- `#/blast/` — impact panel over `/v1/blast_radius` +- recent-request stream from `/v1/traces/recent`, polled every 5s +- no Chart.js, Cytoscape, topology-first UI, Ask panel, deploy diff, or large dashboard charts - ## Architecture ```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 - └─ SSE dashboard · health · metrics · OpenAPI + └─ v2 dashboard · health · metrics · OpenAPI │ - ├──▶ /ui dashboard (Geist theme, Chart.js, Cytoscape.js) + ├──▶ /ui dashboard (Geist, no vendored chart/topology libs) ├──▶ /v1/tools/* · /v1/plans/execute (agent-native) └──▶ 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). +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 @@ -250,17 +264,18 @@ 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 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,12 +289,14 @@ 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 -- `/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 +- `/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 **Planned:** @@ -298,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 docker-dev`, open , click a failure button, then open to see the propagation chain live. +**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/cmd/ingest/main.go b/cmd/ingest/main.go index 7524172..52c700e 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,10 +22,12 @@ 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" "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" @@ -120,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) @@ -139,6 +143,48 @@ 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) + 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 replay failed", "err", err) + os.Exit(1) + } + 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, + "replay_since", v2ReplaySince, + "dedup_replay_loaded", v2Replay.DedupLoaded, + "replay_projected", v2Replay.Projected, + "replay_decode_fails", v2Replay.DecodeFails, + ) + // Optional SQLite cold store var coldDB coldstore.ManagedStore var coldWriter *coldstore.BatchWriter @@ -188,20 +234,14 @@ func main() { PlanStore: planStore, GraphHotWindow: graphHotWindow, OTLPEnabled: otlpEnabled, + V2ReadsEnabled: v2ReadsEnabled, }) // SSE hub for real-time dashboard updates 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 @@ -302,28 +342,23 @@ func main() { mux.Handle("/metrics", m.Handler()) // Write endpoints. - mux.Handle("/v1/events", writeAuth(http.HandlerFunc(ingestServer.Events))) - mux.Handle("/v1/events/validate", writeAuth(http.HandlerFunc(ingestServer.Validate))) + eventsV2, err := ingestv2.New(ingestv2.Config{ + Metrics: m, + Dedup: v2Dedup, + WAL: v2Wal, + Index: v2Index, + Project: v2Projector, + }) + 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(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") } @@ -334,15 +369,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)) @@ -384,14 +434,6 @@ func main() { mux.HandleFunc("/ui", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/", http.StatusMovedPermanently) }) - mux.Handle("/ui/ask", http.HandlerFunc(ingest.CORSWrap(corsOrigin, "POST, OPTIONS", - func(w http.ResponseWriter, r *http.Request) { - dashGate(http.HandlerFunc(ingestServer.DashboardAsk)).ServeHTTP(w, r) - }))) - mux.Handle("/ui/explain", http.HandlerFunc(ingest.CORSWrap(corsOrigin, "GET, OPTIONS", - func(w http.ResponseWriter, r *http.Request) { - dashGate(http.HandlerFunc(ingestServer.DashboardExplain)).ServeHTTP(w, r) - }))) handler := ingest.CorrelationIDMiddleware(mux) @@ -452,6 +494,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 { @@ -516,7 +562,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() @@ -525,11 +571,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) + } } } } @@ -705,6 +761,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/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/docker-compose.yml b/docker-compose.yml index 329f306..c332ad2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,13 @@ services: zookeeper: + profiles: ["legacy-kafka"] image: confluentinc/cp-zookeeper:7.7.7 environment: ZOOKEEPER_CLIENT_PORT: 2181 ZOOKEEPER_TICK_TIME: 2000 kafka: + profiles: ["legacy-kafka"] image: confluentinc/cp-kafka:7.7.7 depends_on: zookeeper: @@ -44,11 +46,13 @@ services: EVENT_LOG_DIR: /data/eventlog LOG_LEVEL: info CORS_ORIGIN: "*" - WAYLOG_API_KEY: ${WAYLOG_API_KEY:-} - WAYLOG_WRITE_KEY: ${WAYLOG_WRITE_KEY:-} - WAYLOG_READ_KEY: ${WAYLOG_READ_KEY:-} + WAYLOG_API_KEY: ${WAYLOG_API_KEY:-demo} + WAYLOG_WRITE_KEY: ${WAYLOG_WRITE_KEY:-demo} + WAYLOG_READ_KEY: ${WAYLOG_READ_KEY:-demo} WAYLOG_AGENT_KEY: ${WAYLOG_AGENT_KEY:-} - DASHBOARD_AUTH: ${DASHBOARD_AUTH:-off} + WAYLOG_V2_READS: ${WAYLOG_V2_READS:-true} + EVENT_LOG_V2_DIR: /data/eventlog-v2 + DASHBOARD_AUTH: ${DASHBOARD_AUTH:-key:demo} DASHBOARD_SESSION_SECRET: ${DASHBOARD_SESSION_SECRET:-} GEMINI_API_KEY: ${GEMINI_API_KEY:-} GOOGLE_API_KEY: ${GOOGLE_API_KEY:-} @@ -70,6 +74,7 @@ services: retries: 10 bridge: + profiles: ["legacy-kafka"] build: context: . target: bridge @@ -98,49 +103,53 @@ services: context: . target: api-gateway depends_on: - kafka: + ingest: condition: service_healthy ports: - "9081:9081" environment: CHECKOUT_URL: http://checkout-demo:9082 - KAFKA_BROKERS: kafka:29092 - KAFKA_TOPIC: wide_events + INGEST_URL: http://ingest:8080 + WAYLOG_WRITE_KEY: ${WAYLOG_WRITE_KEY:-demo} checkout-demo: build: context: . target: checkout-demo depends_on: - kafka: + ingest: condition: service_healthy + payment-demo: + condition: service_started + db-demo: + condition: service_started environment: PAYMENT_URL: http://payment-demo:9083 DB_URL: http://db-demo:9084 - KAFKA_BROKERS: kafka:29092 - KAFKA_TOPIC: wide_events + INGEST_URL: http://ingest:8080 + WAYLOG_WRITE_KEY: ${WAYLOG_WRITE_KEY:-demo} payment-demo: build: context: . target: payment-demo depends_on: - kafka: + ingest: condition: service_healthy environment: - KAFKA_BROKERS: kafka:29092 - KAFKA_TOPIC: wide_events + INGEST_URL: http://ingest:8080 + WAYLOG_WRITE_KEY: ${WAYLOG_WRITE_KEY:-demo} db-demo: build: context: . target: db-demo depends_on: - kafka: + ingest: condition: service_healthy environment: - KAFKA_BROKERS: kafka:29092 - KAFKA_TOPIC: wide_events + INGEST_URL: http://ingest:8080 + WAYLOG_WRITE_KEY: ${WAYLOG_WRITE_KEY:-demo} prometheus: image: prom/prometheus:v2.53.0 diff --git a/docs/env.md b/docs/env.md index b5e5ea7..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 CLI LLM-backed Ask flows | +| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Required when server-side Ask/tool flows use Gemini | ## 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 | @@ -42,9 +52,12 @@ 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_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. @@ -60,12 +73,28 @@ 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 | | `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 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` | `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` | `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 a schema-2.0 triage surface and renders a setup message unless `WAYLOG_V2_READS=true`. + ## Dashboard links Optional external links rendered in the dashboard header. Hidden if empty. @@ -79,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/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/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/api-gateway/main.go b/examples/cmd/api-gateway/main.go index a7a20af..12ca98e 100644 --- a/examples/cmd/api-gateway/main.go +++ b/examples/cmd/api-gateway/main.go @@ -1,55 +1,16 @@ 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" ) -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 +19,9 @@ func main() { gateway := microdemo.NewGatewayHandler(checkoutURL) mux := http.NewServeMux() - mux.Handle("/purchase", wayloghttp.Middleware(http.HandlerFunc(gateway.ServePurchase))) + mux.Handle("/purchase", gateway.PurchaseHandler()) mux.HandleFunc("/demo", gateway.ServeDemo) + mux.HandleFunc("/demo/burst", gateway.ServeBurst) - 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/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 ab3df85..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 ( @@ -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/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/burst.go b/examples/microdemo/burst.go new file mode 100644 index 0000000..3c76258 --- /dev/null +++ b/examples/microdemo/burst.go @@ -0,0 +1,163 @@ +package microdemo + +import ( + "bytes" + "context" + "encoding/json" + "math/rand/v2" + "net/http" + "net/http/httptest" + "sync" + "time" +) + +const ( + defaultBurstRequests = 50 + defaultBurstConcurrency = 10 + maxBurstRequests = 250 + maxBurstConcurrency = 50 + maxBurstSamples = 5 +) + +type BurstRequest struct { + Requests int `json:"requests,omitempty"` + Concurrency int `json:"concurrency,omitempty"` +} + +type BurstSummary struct { + Requested BurstRequest `json:"requested"` + Accepted BurstRequest `json:"accepted"` + DurationMs int64 `json:"duration_ms"` + ByScenario map[string]int `json:"by_scenario"` + OK int `json:"ok"` + Errors int `json:"errors"` + Suppressed int `json:"suppressed"` + SampleTraceIDs []string `json:"sample_trace_ids"` +} + +var scenarioWeights = []struct { + Cutoff float64 + Scenario string +}{ + {0.70, ScenarioHappy}, + {0.85, ScenarioPayment502}, + {0.93, ScenarioDBMiss}, + {0.98, ScenarioCheckoutError}, + {1.00, ScenarioSuppressedPayment502}, +} + +func pickBurstScenarioFloat(x float64) string { + for _, weight := range scenarioWeights { + if x < weight.Cutoff { + return weight.Scenario + } + } + return ScenarioSuppressedPayment502 +} + +func pickBurstScenario() string { + return pickBurstScenarioFloat(rand.Float64()) +} + +func normalizeBurstRequest(raw BurstRequest) (requested, accepted BurstRequest) { + requested = raw + if requested.Requests == 0 { + requested.Requests = defaultBurstRequests + } + if requested.Concurrency == 0 { + requested.Concurrency = defaultBurstConcurrency + } + + accepted = requested + if accepted.Requests < 1 { + accepted.Requests = 1 + } + if accepted.Requests > maxBurstRequests { + accepted.Requests = maxBurstRequests + } + if accepted.Concurrency < 1 { + accepted.Concurrency = 1 + } + if accepted.Concurrency > maxBurstConcurrency { + accepted.Concurrency = maxBurstConcurrency + } + if accepted.Concurrency > accepted.Requests { + accepted.Concurrency = accepted.Requests + } + return requested, accepted +} + +func runBurst(ctx context.Context, dispatch http.Handler, raw BurstRequest) BurstSummary { + requested, accepted := normalizeBurstRequest(raw) + summary := BurstSummary{ + Requested: requested, + Accepted: accepted, + ByScenario: map[string]int{}, + } + if dispatch == nil { + return summary + } + + start := time.Now() + sem := make(chan struct{}, accepted.Concurrency) + var wg sync.WaitGroup + var mu sync.Mutex + sampledScenarios := map[string]struct{}{} + + for i := 0; i < accepted.Requests; i++ { + if ctx.Err() != nil { + break + } + // Acquire semaphore before spawning so live goroutines stay capped at + // concurrency instead of stacking up `requests` blocked goroutines. + sem <- struct{}{} + wg.Add(1) + go func() { + defer wg.Done() + defer func() { <-sem }() + + scenario := pickBurstScenario() + payload, _ := json.Marshal(PurchaseRequest{ + SKU: "X1", + Scenario: scenario, + }) + req := httptest.NewRequest(http.MethodPost, "/purchase", bytes.NewReader(payload)).WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + dispatch.ServeHTTP(rec, req) + + var resp struct { + Success bool `json:"success"` + TraceID string `json:"trace_id"` + Scenario string `json:"scenario"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + resp.Scenario = scenario + } + if resp.Scenario == "" { + resp.Scenario = scenario + } + + mu.Lock() + defer mu.Unlock() + summary.ByScenario[resp.Scenario]++ + switch { + case resp.Scenario == ScenarioSuppressedPayment502: + summary.Suppressed++ + case resp.Success: + summary.OK++ + default: + summary.Errors++ + } + if resp.TraceID != "" { + if _, ok := sampledScenarios[resp.Scenario]; !ok && len(sampledScenarios) < maxBurstSamples { + sampledScenarios[resp.Scenario] = struct{}{} + summary.SampleTraceIDs = append(summary.SampleTraceIDs, resp.TraceID) + } + } + }() + } + wg.Wait() + summary.DurationMs = time.Since(start).Milliseconds() + return summary +} diff --git a/examples/microdemo/burst_test.go b/examples/microdemo/burst_test.go new file mode 100644 index 0000000..eaa914b --- /dev/null +++ b/examples/microdemo/burst_test.go @@ -0,0 +1,164 @@ +package microdemo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestPickBurstScenarioFloatBoundaries(t *testing.T) { + tests := []struct { + x float64 + want string + }{ + {0.00, ScenarioHappy}, + {0.699, ScenarioHappy}, + {0.70, ScenarioPayment502}, + {0.849, ScenarioPayment502}, + {0.85, ScenarioDBMiss}, + {0.929, ScenarioDBMiss}, + {0.93, ScenarioCheckoutError}, + {0.979, ScenarioCheckoutError}, + {0.98, ScenarioSuppressedPayment502}, + {0.999, ScenarioSuppressedPayment502}, + {1.0, ScenarioSuppressedPayment502}, + } + for _, tt := range tests { + if got := pickBurstScenarioFloat(tt.x); got != tt.want { + t.Fatalf("pickBurstScenarioFloat(%v) = %q, want %q", tt.x, got, tt.want) + } + } +} + +func TestPickBurstScenarioFloatAllScenariosReachable(t *testing.T) { + seen := map[string]bool{} + for _, x := range []float64{0.1, 0.75, 0.88, 0.95, 0.99} { + seen[pickBurstScenarioFloat(x)] = true + } + for _, scenario := range []string{ + ScenarioHappy, + ScenarioPayment502, + ScenarioDBMiss, + ScenarioCheckoutError, + ScenarioSuppressedPayment502, + } { + if !seen[scenario] { + t.Fatalf("scenario %q was not reachable; seen=%v", scenario, seen) + } + } +} + +func TestRunBurstDispatchesEveryRequestThroughHandler(t *testing.T) { + var calls atomic.Int64 + dispatch := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("content-type = %q, want application/json", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"trace_id":"t","scenario":"happy"}`)) + }) + + summary := runBurst(t.Context(), dispatch, BurstRequest{Requests: 20, Concurrency: 4}) + if calls.Load() != 20 { + t.Fatalf("dispatch calls = %d, want 20", calls.Load()) + } + if summary.Accepted.Requests != 20 || summary.Accepted.Concurrency != 4 { + t.Fatalf("accepted = %#v, want 20/4", summary.Accepted) + } + if summary.OK != 20 || summary.Errors != 0 || summary.Suppressed != 0 { + t.Fatalf("summary counts = ok:%d errors:%d suppressed:%d", summary.OK, summary.Errors, summary.Suppressed) + } + if summary.ByScenario[ScenarioHappy] != 20 { + t.Fatalf("happy count = %d, want 20", summary.ByScenario[ScenarioHappy]) + } +} + +func TestServeBurstRejectsNonPOST(t *testing.T) { + gateway := NewGatewayHandler("http://checkout.example") + gateway.SetPurchaseHandler(okBurstDispatch()) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/demo/burst", nil) + gateway.ServeBurst(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want 405", rec.Code) + } +} + +func TestServeBurstRejectsUnknownFields(t *testing.T) { + rec := serveBurstForTest(t, `{"foo":1}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestServeBurstRejectsMalformedJSON(t *testing.T) { + rec := serveBurstForTest(t, `garbage`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestServeBurstClampsLimitsAndEchoesRequested(t *testing.T) { + rec := serveBurstForTest(t, `{"requests":9999,"concurrency":9999}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var summary BurstSummary + if err := json.Unmarshal(rec.Body.Bytes(), &summary); err != nil { + t.Fatalf("unmarshal summary: %v", err) + } + if summary.Requested.Requests != 9999 || summary.Requested.Concurrency != 9999 { + t.Fatalf("requested = %#v, want 9999/9999", summary.Requested) + } + if summary.Accepted.Requests != maxBurstRequests || summary.Accepted.Concurrency != maxBurstConcurrency { + t.Fatalf("accepted = %#v, want %d/%d", summary.Accepted, maxBurstRequests, maxBurstConcurrency) + } +} + +func TestServeBurstAppliesDefaultsWhenZero(t *testing.T) { + rec := serveBurstForTest(t, `{}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var summary BurstSummary + if err := json.Unmarshal(rec.Body.Bytes(), &summary); err != nil { + t.Fatalf("unmarshal summary: %v", err) + } + if summary.Requested.Requests != defaultBurstRequests || summary.Requested.Concurrency != defaultBurstConcurrency { + t.Fatalf("requested defaults = %#v, want %d/%d", summary.Requested, defaultBurstRequests, defaultBurstConcurrency) + } + if summary.Accepted.Requests != defaultBurstRequests || summary.Accepted.Concurrency != defaultBurstConcurrency { + t.Fatalf("accepted defaults = %#v, want %d/%d", summary.Accepted, defaultBurstRequests, defaultBurstConcurrency) + } +} + +func serveBurstForTest(t *testing.T, body string) *httptest.ResponseRecorder { + t.Helper() + gateway := NewGatewayHandler("http://checkout.example") + gateway.SetPurchaseHandler(okBurstDispatch()) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/demo/burst", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + gateway.ServeBurst(rec, req) + return rec +} + +func okBurstDispatch() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req PurchaseRequest + _ = json.NewDecoder(r.Body).Decode(&req) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": req.Scenario == ScenarioHappy, + "trace_id": "trace-" + req.Scenario, + "scenario": req.Scenario, + }) + }) +} diff --git a/examples/microdemo/checkout.go b/examples/microdemo/checkout.go index e14a917..91d5393 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,153 @@ func NewCheckoutHandler(paymentURL, dbURL string) *CheckoutHandler { paymentURL: paymentURL, dbURL: dbURL, paymentClient: &http.Client{ - Transport: wayloghttp.WrapTransport(http.DefaultTransport, "payment"), + Transport: wayloghttp.NewTransport(demoHTTPTransport(), "payment"), }, dbClient: &http.Client{ - Transport: wayloghttp.WrapTransport(http.DefaultTransport, "db"), + Transport: wayloghttp.NewTransport(demoHTTPTransport(), "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 + reqBody, err := parsePurchaseRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } + setDemoFields(ctx, "checkout", reqBody) - 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"}) + if reqBody.Scenario == ScenarioSuppressedPayment502 { + h.serveSuppressedPayment(w, reqBody, ctx) + return + } + if err := h.validateCart(ctx, reqBody); err != nil { w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]any{ - "success": false, - "trace_id": traceID, - "error": "checkout processing failed", - }) + _ = json.NewEncoder(w).Encode(response(ctx, false, reqBody, "checkout validation failed")) return } - - dbReq, err := http.NewRequestWithContext(ctx, "GET", h.dbURL+"/db?force="+force, nil) - if err != nil { + 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": err.Error(), - }) + _ = json.NewEncoder(w).Encode(response(ctx, false, reqBody, err.Error())) 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.reserveInventory(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, err.Error())) 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) - if err != nil { + if err := h.commitOrder(ctx, reqBody); err != nil { w.WriteHeader(http.StatusBadGateway) - json.NewEncoder(w).Encode(map[string]any{ - "success": false, - "trace_id": traceID, - "error": err.Error(), - }) + _ = json.NewEncoder(w).Encode(response(ctx, false, reqBody, err.Error())) return } - 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", + 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) validateCart(ctx context.Context, reqBody PurchaseRequest) error { + return waylogv2.StepVoid(ctx, "cart.validate", func(ctx context.Context) error { + if reqBody.Scenario == ScenarioCheckoutError { + return waylogv2.NewError("CHK_500", waylogv2.WithReason("invalid cart state: missing tenant binding")) + } + waylogv2.From(ctx).Info("cart validated", waylogv2.F{"sku": reqBody.SKU}) + return nil + }) +} + +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.StatusNotFound { + return waylogv2.NewError("CART_NOT_FOUND", waylogv2.WithReason("cart record not found for sku")) + } + if resp.StatusCode >= http.StatusInternalServerError { + return waylogv2.NewError("DB_503", waylogv2.WithReason("database unavailable")) + } + waylogv2.From(ctx).Info("cart loaded", waylogv2.F{ + "sku": reqBody.SKU, + "items_n": 3, + "cart_value_cents": 4299, }) - return - } - defer resp.Body.Close() + return nil + }) +} - body, _ := io.ReadAll(resp.Body) +func (h *CheckoutHandler) reserveInventory(ctx context.Context, reqBody PurchaseRequest) error { + return waylogv2.StepVoid(ctx, "inventory.reserve", func(ctx context.Context) error { + waylogv2.From(ctx).Info("inventory reserved", waylogv2.F{ + "sku": reqBody.SKU, + "reservation_id": "res-" + reqBody.SKU, + }) + return nil + }) +} - if resp.StatusCode >= http.StatusInternalServerError { - waylog.Error(ctx, codedError{code: "CHK_DOWNSTREAM", message: "downstream payment failed"}) - } +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) commitOrder(ctx context.Context, reqBody PurchaseRequest) error { + return waylogv2.StepVoid(ctx, "order.commit", func(ctx context.Context) error { + waylogv2.From(ctx).Info("order committed", waylogv2.F{ + "sku": reqBody.SKU, + "order_id": "ord-" + reqBody.SKU, + }) + return nil + }) +} - w.WriteHeader(resp.StatusCode) - w.Write(body) +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 { + return 0, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := h.paymentClient.Do(req) + if err != nil { + return 0, waylogv2.NewError("PMT_502", waylogv2.WithReason("payment service unavailable")) + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + return resp.StatusCode, nil } diff --git a/examples/microdemo/db.go b/examples/microdemo/db.go index 9abb0f6..dba74dd 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,40 @@ 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"}) - w.WriteHeader(http.StatusServiceUnavailable) - json.NewEncoder(w).Encode(map[string]any{ - "success": false, - "trace_id": traceID, - "error": "database unavailable", + if reqBody.Scenario == ScenarioDBMiss { + _ = waylogv2.StepVoid(ctx, "cart.lookup", func(ctx context.Context) error { + return waylogv2.NewError("CART_NOT_FOUND", waylogv2.WithReason("cart record not found for sku")) }) + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(response(ctx, false, reqBody, "cart record not found")) return } + if err := loadCart(ctx); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + _ = 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..a6a7fac 100644 --- a/examples/microdemo/gateway.go +++ b/examples/microdemo/gateway.go @@ -1,14 +1,26 @@ package microdemo import ( + "bytes" + "context" _ "embed" "encoding/json" "io" "net/http" + "strings" - 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" + ScenarioDBMiss = "db_miss" + ScenarioCheckoutError = "checkout_error" + + demoUserID = "demo-user" ) //go:embed ui.html @@ -17,53 +29,72 @@ var uiHTML []byte type GatewayHandler struct { checkoutURL string client *http.Client - uiHTML []byte + purchase http.Handler +} + +type PurchaseRequest struct { + SKU string `json:"sku"` + Scenario string `json:"scenario"` } func NewGatewayHandler(checkoutURL string) *GatewayHandler { - return &GatewayHandler{ + h := &GatewayHandler{ checkoutURL: checkoutURL, client: &http.Client{ - Transport: wayloghttp.WrapTransport(http.DefaultTransport, "checkout"), + Transport: wayloghttp.NewTransport(demoHTTPTransport(), "checkout"), }, - uiHTML: uiHTML, } + // Pre-wrap so the live /purchase route and /demo/burst dispatch share a + // single instance — and so callers can't forget to wire it up. + h.purchase = wayloghttp.HTTP(http.HandlerFunc(h.ServePurchase)) + return h +} + +// PurchaseHandler returns the wayloghttp-wrapped /purchase handler. Use this +// to register the route so /demo/burst dispatches through the same chain. +func (h *GatewayHandler) PurchaseHandler() http.Handler { + return h.purchase +} + +// SetPurchaseHandler overrides the handler used by /demo/burst. Test seam. +func (h *GatewayHandler) SetPurchaseHandler(handler http.Handler) { + h.purchase = handler } 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 +106,58 @@ 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") +func (h *GatewayHandler) ServeBurst(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + http.Error(w, "content-type must be application/json", http.StatusUnsupportedMediaType) + return + } + if h.purchase == nil { + http.Error(w, "purchase handler unavailable", http.StatusServiceUnavailable) + return + } - if resp.StatusCode >= http.StatusInternalServerError { - waylog.Error(ctx, codedError{code: "GW_DOWNSTREAM", message: "downstream checkout failed"}) + var req BurstRequest + if r.Body != nil { + defer r.Body.Close() + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil && err != io.EOF { + http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest) + return + } } - w.WriteHeader(resp.StatusCode) - w.Write(body) + summary := runBurst(r.Context(), h.purchase, req) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(summary) +} + +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 + } + } + if scenario := r.URL.Query().Get("scenario"); scenario != "" { + req.Scenario = scenario + } + 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..71aa33f 100644 --- a/examples/microdemo/microdemo_test.go +++ b/examples/microdemo/microdemo_test.go @@ -1,142 +1,326 @@ 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, "cart.validate", eventv2.StepStatusOK, "") + requireStep(t, ev, "db.load_cart", eventv2.StepStatusOK, "db") + requireStep(t, ev, "inventory.reserve", eventv2.StepStatusOK, "") + requireStep(t, ev, "payment.charge", eventv2.StepStatusError, "payment") + requireNoStep(t, ev, "order.commit") + 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 TestCheckoutDBMissEmitsCartNotFoundAnchor(t *testing.T) { + out := initSDK(t, "checkout") + db := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"success":false,"error":"cart record not found"}`)) + })) + defer db.Close() + payment := httptest.NewServer(okJSON()) + defer payment.Close() + + resp := postPurchase(t, wayloghttp.HTTP(microdemo.NewCheckoutHandler(payment.URL, db.URL)), "/checkout", microdemo.ScenarioDBMiss) + if resp.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", resp.Code, http.StatusBadGateway) + } + ev := oneEvent(t, out) + if ev.Status != eventv2.StatusError { + t.Fatalf("status = %s, want error", ev.Status) + } + if ev.Anchor == nil || ev.Anchor.Step != "db.load_cart" || ev.Anchor.ErrorCode != "CART_NOT_FOUND" { + t.Fatalf("anchor = %#v, want db.load_cart/CART_NOT_FOUND", ev.Anchor) + } + requireStep(t, ev, "cart.validate", eventv2.StepStatusOK, "") + requireStep(t, ev, "db.load_cart", eventv2.StepStatusError, "db") + requireNoStep(t, ev, "inventory.reserve") + requireNoStep(t, ev, "payment.charge") + requireNoStep(t, ev, "order.commit") +} + +func TestCheckoutInternalErrorEmitsCHK500WithoutDownstream(t *testing.T) { + out := initSDK(t, "checkout") + db := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("db should not be called for checkout_error scenario") + })) + defer db.Close() + payment := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("payment should not be called for checkout_error scenario") + })) + defer payment.Close() + + resp := postPurchase(t, wayloghttp.HTTP(microdemo.NewCheckoutHandler(payment.URL, db.URL)), "/checkout", microdemo.ScenarioCheckoutError) + if resp.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", resp.Code, http.StatusInternalServerError) + } + ev := oneEvent(t, out) + if ev.Status != eventv2.StatusError { + t.Fatalf("status = %s, want error", ev.Status) + } + if ev.Anchor == nil || ev.Anchor.Step != "cart.validate" || ev.Anchor.ErrorCode != "CHK_500" { + t.Fatalf("anchor = %#v, want cart.validate/CHK_500", ev.Anchor) + } + requireStep(t, ev, "cart.validate", eventv2.StepStatusError, "") + requireNoStep(t, ev, "db.load_cart") + requireNoStep(t, ev, "inventory.reserve") + requireNoStep(t, ev, "payment.charge") + requireNoStep(t, ev, "order.commit") +} + +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, "cart.validate", eventv2.StepStatusOK, "") + requireStep(t, ev, "db.load_cart", eventv2.StepStatusOK, "db") + requireStep(t, ev, "inventory.reserve", eventv2.StepStatusOK, "") + requireStep(t, ev, "payment.charge", eventv2.StepStatusOK, "payment") + requireStep(t, ev, "order.commit", eventv2.StepStatusOK, "") + requireLog(t, ev, eventv2.LogLevelInfo, "cart validated") + requireLog(t, ev, eventv2.LogLevelInfo, "cart loaded") + requireLog(t, ev, eventv2.LogLevelInfo, "inventory reserved") + requireLog(t, ev, eventv2.LogLevelInfo, "order committed") + 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(), "Run payment outage") { + 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 hasStep(ev eventv2.Event, name string) bool { + for _, step := range ev.Steps { + if step.Name == name { + return true } - defer resp.Body.Close() + } + return false +} - 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 requireNoStep(t *testing.T, ev eventv2.Event, name string) { + t.Helper() + if hasStep(ev, name) { + t.Fatalf("unexpected step %q in %#v", name, ev.Steps) + } +} + +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 } - }) + } + t.Fatalf("missing 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 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) } } +} - fmt.Printf("Integration test passed: %d scenarios verified\n", len(tests)+1) +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) + } +} + +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..db0e5c3 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,38 @@ 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, ScenarioDBMiss, ScenarioCheckoutError: + // db_miss and checkout_error fail upstream of payment, but defensive + // happy-path payment is fine if checkout ever calls us. + 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..b83c699 --- /dev/null +++ b/examples/microdemo/scenario.go @@ -0,0 +1,53 @@ +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 + case ScenarioDBMiss: + return ScenarioDBMiss + case ScenarioCheckoutError: + return ScenarioCheckoutError + 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/transport.go b/examples/microdemo/transport.go new file mode 100644 index 0000000..1c2d22b --- /dev/null +++ b/examples/microdemo/transport.go @@ -0,0 +1,15 @@ +package microdemo + +import ( + "net/http" + "time" +) + +func demoHTTPTransport() *http.Transport { + return &http.Transport{ + MaxConnsPerHost: 64, + MaxIdleConnsPerHost: 32, + MaxIdleConns: 128, + IdleConnTimeout: 30 * time.Second, + } +} diff --git a/examples/microdemo/ui.html b/examples/microdemo/ui.html index 976754f..30ede80 100644 --- a/examples/microdemo/ui.html +++ b/examples/microdemo/ui.html @@ -2,38 +2,790 @@ - - Waylog Micro-Demo + + + Waylog — Live demo + + + -

Waylog Micro-Demo

-
- - - - -
-
Click a button to send a request...
+ +
+
+ Waylog + / + live demo +
+ streaming events +
+ +
+
+

Simulate a checkout outage. Watch Waylog explain the cascade.

+

Trigger a real request through api-gatewaycheckoutdb/payment, then jump into Waylog to see the failure path, blast radius, and trace narrative.

+
+ + + + + +
+
+ +
+

Scenarios

+
+
+ Happy checkout + Captured as a successful recent request; no error family appears. +
+
+ Payment outage + Checkout records payment.charge with PMT_502; dashboard shows the failure path and blast radius. +
+
+ Cart not found + DB returns 404; checkout records db.load_cart with CART_NOT_FOUND — a logical (not infra) failure. +
+
+ Checkout 500 + Checkout fails before any downstream call: cart.validate raises CHK_500 with no payment or db hop. +
+
+ Suppressed known issue + Visible in recent/direct trace views, excluded from error rollups and blast radius. +
+
+
+ +
+
+

Production-like traffic mix

+

Fires concurrent purchases through the same chain, with realistic checkout steps and failure cutoffs.

+
+
+ + + +
+
+ +
+

Result

+
Choose a scenario to send a real request through the demo services.
+
+
+ + + 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/cli/v2/client.go b/internal/cli/v2/client.go new file mode 100644 index 0000000..0ce7100 --- /dev/null +++ b/internal/cli/v2/client.go @@ -0,0 +1,259 @@ +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) 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 eventGetResponse + err := c.do(ctx, "/v1/events/"+url.PathEscape(eventID), nil, &out) + return out.Event, 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..f6617f1 --- /dev/null +++ b/internal/cli/v2/cmd.go @@ -0,0 +1,445 @@ +package cliv2 + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "strconv" + "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 "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": + 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 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", "", "") + 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 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]") + } + 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 { + p, err := parseBlastArgs(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 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]") + } + 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 { + p, query, err := parseSearchArgs(args) + if err != nil { + return usage(stderr, err.Error()) + } + 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 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) + 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 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) + --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..3f038f8 --- /dev/null +++ b/internal/cli/v2/cmd_test.go @@ -0,0 +1,209 @@ +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 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":{"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) { + 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 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) + 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..e4051cc --- /dev/null +++ b/internal/cli/v2/render.go @@ -0,0 +1,263 @@ +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 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) + 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 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) + } +} + +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..598c54c --- /dev/null +++ b/internal/cli/v2/render_test.go @@ -0,0 +1,114 @@ +package cliv2 + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + 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 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 { + 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..6672d99 --- /dev/null +++ b/internal/cli/v2/types.go @@ -0,0 +1,82 @@ +package cliv2 + +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 +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 eventGetResponse struct { + Event *Event `json:"event"` +} + +type ErrorsParams struct { + Window string + Service string + Limit int + Cursor string +} + +type RecentParams struct { + Window string + Service string + Status string + Limit int + Cursor string + IncludeSuppressed bool +} + +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/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/eventlog/v2/replay.go b/internal/eventlog/v2/replay.go new file mode 100644 index 0000000..c0a9fbb --- /dev/null +++ b/internal/eventlog/v2/replay.go @@ -0,0 +1,131 @@ +package eventlogv2 + +import ( + "bufio" + "bytes" + "io" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const maxReplayLineBytes = (1 << 20) + 1 + +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 + } + 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 + } + if !since.IsZero() && info.ModTime().Before(since) { + continue + } + 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 := replayFileLines(file.path, fn) + loaded += n + if err != nil { + return loaded, err + } + } + return loaded, nil +} + +type replayFile struct { + path string + name string + modTime time.Time +} + +func replayFileLines(path string, fn func(rawLine []byte) error) (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 + } + if len(line) > 0 && fn != nil { + if err := fn(line); err != nil { + return loaded, err + } + 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') + // 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 + 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..9d80465 --- /dev/null +++ b/internal/eventlog/v2/replay_test.go @@ -0,0 +1,143 @@ +package eventlogv2 + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +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") + + 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) + } + ids = append(ids, v["event_id"]) + return nil + }) + if err != nil { + t.Fatal(err) + } + if count != 6 || strings.Join(ids, ",") != "a,b,c,d,e,f" { + t.Fatalf("count=%d ids=%v", count, ids) + } +} + +func TestReplayEmptyDir(t *testing.T) { + count, err := Replay(t.TempDir(), time.Time{}, func([]byte) error { return nil }) + if err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("count=%d want 0", count) + } +} + +func TestReplayAcceptsOneMBLine(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) + } + + 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 count != 1 { + t.Fatalf("count=%d want 1", count) + } +} + +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) + } + + 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 count != 1 || len(ids) != 1 || ids[0] != "new" { + t.Fatalf("count=%d ids=%v", count, ids) + } +} + +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 b1c8768..bd1a4e8 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 @@ -141,7 +136,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 +202,7 @@ type ServerConfig struct { PlanStore *PlanStore GraphHotWindow time.Duration OTLPEnabled bool + V2ReadsEnabled bool } // NewServer creates a new ingest server with the given configuration. @@ -244,7 +241,7 @@ func NewServer(cfg ServerConfig) *Server { planStore: cfg.PlanStore, graphHotWindow: cfg.GraphHotWindow, otlpEnabled: cfg.OTLPEnabled, - rateLimit: map[string][]time.Time{}, + v2ReadsEnabled: cfg.V2ReadsEnabled, replayStatus: "none", } if s.sampler == nil { @@ -366,190 +363,6 @@ 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. -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 @@ -565,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 @@ -759,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, }, @@ -771,6 +583,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{ @@ -2309,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 != "" { @@ -2433,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 { @@ -2549,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 c85928f..0c99c6c 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" @@ -566,63 +592,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}}` - 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()}) @@ -694,26 +663,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()}) @@ -905,8 +854,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 +866,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 +907,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() @@ -2066,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/pipeline.go b/internal/ingest/pipeline.go index 182f9fe..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) @@ -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() } } @@ -238,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/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/anchor.go b/internal/ingest/v2/anchor.go new file mode 100644 index 0000000..89015fd --- /dev/null +++ b/internal/ingest/v2/anchor.go @@ -0,0 +1,147 @@ +package ingestv2 + +import ( + "sort" + + 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) && ev.Status.IsFailed() { + 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 +} 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/dedup.go b/internal/ingest/v2/dedup.go new file mode 100644 index 0000000..1abc661 --- /dev/null +++ b/internal/ingest/v2/dedup.go @@ -0,0 +1,131 @@ +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 +} + +// 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) + 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..2437f7c --- /dev/null +++ b/internal/ingest/v2/dedup_test.go @@ -0,0 +1,95 @@ +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) + } +} + +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/derived.go b/internal/ingest/v2/derived.go new file mode 100644 index 0000000..c246959 --- /dev/null +++ b/internal/ingest/v2/derived.go @@ -0,0 +1,446 @@ +package ingestv2 + +import ( + "sort" + "time" + + apiv2 "github.com/sssmaran/WaylogCLI/pkg/api/v2" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +const LinkageDirect = apiv2.LinkageDirect + +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 + Rows []ErrorRow + NextCursor *ErrorCursor +} + +type BlastKey = apiv2.BlastKey +type BlastRadiusResult = apiv2.BlastRadiusResponse + +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 := apiv2.BlastViewSingleFamily + if key.CrossCode { + viewMode = apiv2.BlastViewCrossFamily + } + 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 != eventv2.LogLevelWarn && log.Level != eventv2.LogLevelError { + 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 == eventv2.StepStatusError && (!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 || !ev.Status.IsFailed() { + 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 || !ev.Status.IsFailed() || !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 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..a6c1d87 --- /dev/null +++ b/internal/ingest/v2/derived_test.go @@ -0,0 +1,193 @@ +package ingestv2 + +import ( + "encoding/json" + "net/http" + "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: eventv2.StepStatusOK}} + 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: 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: eventv2.LogLevelWarn, Msg: "early"}, + {TsOffsetMS: 24, Level: eventv2.LogLevelError, Msg: "anchor"}, + {TsOffsetMS: 31, Level: eventv2.LogLevelError, 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 != apiv2.BlastViewSingleFamily || 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 != apiv2.BlastViewCrossFamily || 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 != apiv2.BlastViewSingleFamily || 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 := 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 := ParseErrorFamily(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: eventv2.StepStatusError, 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/envelope.go b/internal/ingest/v2/envelope.go new file mode 100644 index 0000000..6067da0 --- /dev/null +++ b/internal/ingest/v2/envelope.go @@ -0,0 +1,48 @@ +// 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" + ReasonDurabilityUnavailable = "durability_unavailable" +) + +// 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/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/handler.go b/internal/ingest/v2/handler.go new file mode 100644 index 0000000..1155424 --- /dev/null +++ b/internal/ingest/v2/handler.go @@ -0,0 +1,296 @@ +package ingestv2 + +import ( + "context" + "encoding/json" + "errors" + "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" +) + +// Handler serves the schema-2.0 ingest contract mounted at POST /v1/events. +type Handler struct { + schema *jsonschema.Schema + metrics *metrics.Metrics + dedup *Dedup + wal WAL + project EventProjector +} + +type WAL interface { + WriteRaw([]byte) error +} + +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 +// pre-initializes the rejection-reason label series this handler emits so +// dashboards see zero-valued series before the first hit. +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) + } + } + dedup := cfg.Dedup + if dedup == nil { + var sizeGauge interface{ Set(float64) } + if m != nil { + sizeGauge = m.EventDedupCacheSize + } + dedup = NewDedup(DefaultDedupCapacity, sizeGauge) + } + 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 ( + errDurabilityUnavailable = errors.New("durability unavailable") + errProjectionUnavailable = errors.New("projection unavailable") +) + +var rejectionReasons = []string{ + ReasonInvalidJSON, + ReasonSchemaValidationFailed, + ReasonBridgeNotImplemented, + ReasonBatchOversize, + ReasonBodyOversize, + ReasonUnsupportedContentType, + ReasonUnsupportedEncoding, + ReasonInvalidBody, + ReasonDurabilityUnavailable, +} + +// Events validates schema-2.0 JSON/NDJSON ingest requests and returns the +// §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() + 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 + } + 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() + 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 { + h.metrics.V2ValidateLatency.Observe(time.Since(validateStart).Seconds()) + } + if !result.ok { + env.Rejected = append(env.Rejected, result.rejected) + h.recordRejected(result.rejected.Reason) + continue + } + if !durable { + env.Accepted++ + continue + } + if h.wal == nil { + h.recordRejected(ReasonDurabilityUnavailable) + return env, errDurabilityUnavailable + } + var duplicate bool + var err error + if h.dedup == nil { + err = h.writeWAL(eventBody) + } else { + duplicate, err = h.dedup.AddIfNew(result.eventID, func() error { + return h.writeWAL(eventBody) + }) + } + if err != nil { + h.recordRejected(ReasonDurabilityUnavailable) + return env, errDurabilityUnavailable + } + if duplicate { + env.Duplicate++ + if h.metrics != nil { + h.metrics.EventsDuplicate.Inc() + } + continue + } + if err := h.projectEvent(result.eventID, result.event); err != nil { + return env, err + } + if h.metrics != nil { + h.metrics.EventsAccepted.Inc() + } + env.Accepted++ + } + return env, nil +} + +type validationResult struct { + ok bool + eventID string + event *eventv2.Event + rejected RejectedEvent +} + +func (h *Handler) validateEvent(index int, eventBody []byte) validationResult { + var raw any + if err := json.Unmarshal(eventBody, &raw); err != nil { + return validationResult{rejected: RejectedEvent{Index: index, Reason: ReasonInvalidJSON, Detail: err.Error()}} + } + + eventID, schemaVersion := rawIdentifiers(raw) + if strings.HasPrefix(schemaVersion, "1.") { + 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 validationResult{eventID: eventID, rejected: RejectedEvent{Index: index, EventID: eventID, Reason: ReasonSchemaValidationFailed, Detail: err.Error()}} + } + 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) { + 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 (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(eventID string, ev *eventv2.Event) (err error) { + 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) + err = errProjectionUnavailable + } + }() + if h.project != nil { + h.project.Project(ev) + } + if h.metrics != nil { + h.metrics.V2EventsProjected.Inc() + } + return nil +} + +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..733868c --- /dev/null +++ b/internal/ingest/v2/handler_test.go @@ -0,0 +1,824 @@ +package ingestv2 + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "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" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +func TestEventsSingleJSONValid(t *testing.T) { + h, wal := 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"]) + } + 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" + + 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) + } +} + +func TestEventsUnsupportedContentEncoding(t *testing.T) { + for _, encoding := range []string{"br", "deflate", "compress"} { + t.Run(encoding, func(t *testing.T) { + 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) + } + }) + } +} + +func TestEventsBodySizeBoundaries(t *testing.T) { + exact := exactSizedJSON(t, maxBodyBytes) + 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) + 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) + } + + 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) + } +} + +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) + 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) + } + 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 != 0 || env.Duplicate != 3 { + t.Fatalf("env=%+v", env) + } +} + +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++ { + b.WriteString(validEventJSON(fmt.Sprintf("00000000-0000-4000-8000-%012d", i+1))) + b.WriteByte('\n') + } + 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) { + 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) + } +} + +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) + + 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", offset+i+1))) + b.WriteByte('\n') + } + offset += 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) + } + 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) + 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) + 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) + } + 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{ + 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) + 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"), + 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") + } + 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{} + 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) + + 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()) + } + 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 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{} + h := newTestHandlerWithConfig(t, Config{Metrics: m, Dedup: NewDedup(DefaultDedupCapacity, nil), WAL: wal}) + 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}) +} + +func newTestHandlerWithConfig(t *testing.T, cfg Config) *Handler { + t.Helper() + h, err := New(cfg) + 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 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 { + 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 +} + +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" } + +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..142051d --- /dev/null +++ b/internal/ingest/v2/index.go @@ -0,0 +1,346 @@ +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) 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{} + } + 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 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 { + 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 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/linkage.go b/internal/ingest/v2/linkage.go new file mode 100644 index 0000000..43cb370 --- /dev/null +++ b/internal/ingest/v2/linkage.go @@ -0,0 +1,170 @@ +package ingestv2 + +import ( + "sort" + + apiv2 "github.com/sssmaran/WaylogCLI/pkg/api/v2" + eventv2 "github.com/sssmaran/WaylogCLI/pkg/event/v2" +) + +const ( + LinkageCausal = apiv2.LinkageCausal + LinkageTimestampFallback = apiv2.LinkageTimestampFallback +) + +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/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/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..041092d --- /dev/null +++ b/internal/ingest/v2/projector_test.go @@ -0,0 +1,195 @@ +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 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 { + 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/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..c8bd848 --- /dev/null +++ b/internal/ingest/v2/reader.go @@ -0,0 +1,260 @@ +package ingestv2 + +import ( + "sort" + "time" + + apiv2 "github.com/sssmaran/WaylogCLI/pkg/api/v2" + 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 = apiv2.TraceSummary + +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, f.IncludeSuppressed) + 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, includeSuppressed bool) (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 := 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() + } + return TraceSummary{ + TraceID: traceID, + TsStart: minStart, + DurationMS: duration, + Services: services, + Status: status, + AnchorSummary: FormatEventErrorFamily(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..33b9a37 --- /dev/null +++ b/internal/ingest/v2/reader_test.go @@ -0,0 +1,133 @@ +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: eventv2.StepStatusOK}} + 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 want suppressed-only trace when explicitly included", got.Traces) + } +} + +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 { + 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..b6341ca --- /dev/null +++ b/internal/ingest/v2/readhandler.go @@ -0,0 +1,485 @@ +package ingestv2 + +import ( + "encoding/json" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sssmaran/WaylogCLI/internal/metrics" + apiv2 "github.com/sssmaran/WaylogCLI/pkg/api/v2" + 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 = apiv2.EventSearchResponse +type traceGetResponse = apiv2.TraceGetResponse +type recentTracesResponse = apiv2.RecentTracesResponse +type errorsResponse = apiv2.ErrorsResponse + +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 !status.IsFailed() { + 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 := ParseErrorFamily(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..6fc44a1 --- /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: eventv2.StepStatusOK}} + 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/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..5eb1c3f --- /dev/null +++ b/internal/ingest/v2/replay_test.go @@ -0,0 +1,340 @@ +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) { + 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 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" + 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) + } +} + +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/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..fd59cf2 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -13,11 +13,28 @@ 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 + EventsDuplicate prometheus.Counter + EventsRejected *prometheus.CounterVec + 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 + V2ReadLatency *prometheus.HistogramVec + V2ReadEmpty *prometheus.CounterVec + V2ReadNotFound *prometheus.CounterVec ReplayLagSeconds prometheus.Gauge ReplayInProgress prometheus.Gauge @@ -73,9 +90,14 @@ 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{ + 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.", @@ -83,16 +105,98 @@ func New(reg *prometheus.Registry) *Metrics { }) m.EventsAccepted = prometheus.NewCounter(prometheus.CounterOpts{ Name: "waylog_events_accepted_total", - Help: "Events merged into graph.", + Help: "Events accepted after each ingest path's durability contract is satisfied.", + }) + 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", 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.", }) + 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.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.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", @@ -270,8 +374,14 @@ func New(reg *prometheus.Registry) *Metrics { }) reg.MustRegister( - m.IngestLatency, m.MergeLatency, - m.EventsAccepted, m.EventsRejected, m.EventlogFails, + 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.V2ReadLatency, m.V2ReadEmpty, m.V2ReadNotFound, m.ReplayLagSeconds, m.ReplayInProgress, m.ReplayFailuresTotal, m.Ready, m.InFlightRequests, m.SnapshotLastSuccess, m.SnapshotLastError, 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/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/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/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..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" @@ -103,7 +122,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 +136,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 +153,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/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) + } + } +} 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/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-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 diff --git a/scripts/demo-stop.sh b/scripts/demo-stop.sh index 4088754..9ed6c09 100755 --- a/scripts/demo-stop.sh +++ b/scripts/demo-stop.sh @@ -1,11 +1,22 @@ #!/usr/bin/env bash set -euo pipefail -# Stop Kafka (if running via compose) -docker compose -f docker-compose.kafka.yml down -v >/dev/null 2>&1 || true - -# Kill any demo processes still running -pkill -f "go run ./cmd/checkout" >/dev/null 2>&1 || true -pkill -f "go run ./cmd/bridge" >/dev/null 2>&1 || true -pkill -f "go run ./cmd/ingest" >/dev/null 2>&1 || true -pkill -f "curl -s http://localhost:9090/checkout" >/dev/null 2>&1 || true +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 4ae7667..aab621d 100755 --- a/scripts/demo.sh +++ b/scripts/demo.sh @@ -1,67 +1,159 @@ #!/usr/bin/env bash set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + GOCACHE_DIR="${GOCACHE:-/tmp/go-build}" -export GOCACHE="$GOCACHE_DIR" +STATE_DIR="${WAYLOG_DEMO_STATE_DIR:-./data/demo-state}" +LOG_DIR="${STATE_DIR}/logs" +BIN_DIR="${STATE_DIR}/bin" -export KAFKA_BROKERS="${KAFKA_BROKERS:-localhost:9092}" -export KAFKA_TOPIC="${KAFKA_TOPIC:-wide_events}" -export INGEST_ADDR="${INGEST_ADDR:-:8080}" -export INGEST_URL="${INGEST_URL:-http://localhost:8080/v1/events}" -export HAPPY_SAMPLE_RATE_PCT="${HAPPY_SAMPLE_RATE_PCT:-100}" +# 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" -CHECKOUT_URL="${CHECKOUT_URL:-http://localhost:9090/checkout}" -TRAFFIC_INTERVAL_SEC="${TRAFFIC_INTERVAL_SEC:-0.5}" +# 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 -pids=() -cleanup() { - for pid in "${pids[@]:-}"; do - kill "$pid" >/dev/null 2>&1 || true - done +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}" - if [[ "${START_KAFKA:-}" == "1" ]]; then - docker compose -f docker-compose.kafka.yml down -v >/dev/null 2>&1 || true - fi +start() { + local name="$1" + shift + local log="${LOG_DIR}/${name}.log" + nohup "$@" >"$log" 2>&1 & + echo "$!" >"${STATE_DIR}/${name}.pid" } -trap cleanup EXIT -handle_interrupt() { - exit 0 +build_bin() { + local name="$1" + local pkg="$2" + go build -o "${BIN_DIR}/${name}" "$pkg" } -trap handle_interrupt INT -if [[ "${START_KAFKA:-}" == "1" ]]; then - docker compose -f docker-compose.kafka.yml up -d -fi +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 +} -# Start checkout service (SDK emits to Kafka) -GOCACHE="$GOCACHE_DIR" go run ./cmd/checkout & -pids+=("$!") +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 +} -# Start Kafka -> ingest bridge -GOCACHE="$GOCACHE_DIR" go run ./cmd/bridge & -pids+=("$!") +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" +} -# Generate traffic -( - while true; do - curl -s "$CHECKOUT_URL" >/dev/null || true - sleep "$TRAFFIC_INTERVAL_SEC" +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 -) & -pids+=("$!") + 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 <" - waylog "explain request " +Open: + Demo controls: http://localhost:9081/demo + Dashboard: http://localhost:8080/ui/ -Press Ctrl+C to stop everything. -INFO +How to demo 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 -# Run ingest in foreground so you can use the REPL for Gemini/tool registry. -GOCACHE="$GOCACHE_DIR" go run ./cmd/ingest 2> ingest.log +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 + +Logs: + ${LOG_DIR} + +Stop: + make demo-stop +INFO 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 diff --git a/scripts/micro-demo-smoke.sh b/scripts/micro-demo-smoke.sh index 678029c..7fd535e 100755 --- a/scripts/micro-demo-smoke.sh +++ b/scripts/micro-demo-smoke.sh @@ -3,16 +3,16 @@ set -euo pipefail GATEWAY_URL="${GATEWAY_URL:-http://localhost:9081}" INGEST_URL="${INGEST_URL:-http://localhost:8080}" +WAYLOG_READ_KEY="${WAYLOG_READ_KEY:-demo}" passed=0 failed=0 -check() { +record_status() { local desc="$1" - local url="$2" + local status="$2" local expected_status="$3" - status=$(curl -s -o /dev/null -w "%{http_code}" "$url" || echo "000") if [[ "$status" == "$expected_status" ]]; then echo "PASS: $desc (HTTP $status)" passed=$((passed + 1)) @@ -22,6 +22,32 @@ check() { fi } +check() { + local desc="$1" + local url="$2" + local expected_status="$3" + + status=$(curl -s -o /dev/null -w "%{http_code}" "$url" || echo "000") + record_status "$desc" "$status" "$expected_status" +} + +post_purchase() { + local scenario="$1" + curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${GATEWAY_URL}/purchase" \ + -H 'Content-Type: application/json' \ + --data "{\"sku\":\"X1\",\"scenario\":\"${scenario}\"}" || echo "000" +} + +check_read() { + local desc="$1" + local url="$2" + local expected_status="$3" + + status=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer ${WAYLOG_READ_KEY}" "$url" || echo "000") + record_status "$desc" "$status" "$expected_status" +} + echo "=== Micro-Demo Smoke Tests ===" echo "" @@ -29,22 +55,22 @@ echo "" check "Gateway UI loads" "${GATEWAY_URL}/demo" "200" # Test 2: Successful purchase -check "Purchase success" "${GATEWAY_URL}/purchase" "200" +record_status "Purchase happy" "$(post_purchase "happy")" "200" # Test 3: Payment failure -check "Purchase with payment_fail" "${GATEWAY_URL}/purchase?force=payment_fail" "502" +record_status "Purchase payment_502" "$(post_purchase "payment_502")" "502" -# Test 4: Checkout failure -check "Purchase with checkout_fail" "${GATEWAY_URL}/purchase?force=checkout_fail" "500" +# Test 4: Suppressed payment failure +record_status "Purchase suppressed_payment_502" "$(post_purchase "suppressed_payment_502")" "502" # Test 5: Ingest health (if running) check "Ingest health" "${INGEST_URL}/healthz" "200" -# Wait for events to flush through Kafka -> bridge -> ingest +# Wait for SDK delivery into ingest. sleep 3 # Extract a trace_id from a purchase response -resp=$(curl -s "${GATEWAY_URL}/purchase") +resp=$(curl -s -X POST "${GATEWAY_URL}/purchase" -H 'Content-Type: application/json' --data '{"sku":"X1","scenario":"payment_502"}') if command -v jq &>/dev/null; then trace_id=$(echo "$resp" | jq -r '.trace_id // empty') else @@ -53,24 +79,24 @@ fi sleep 2 -# Test 6: Overview API -check "Overview API" "${INGEST_URL}/v1/overview?window=5m" "200" +# Test 6: Errors API +check_read "Errors API" "${INGEST_URL}/v1/errors?window=15m" "200" # Test 7: Recent traces API -check "Recent traces API" "${INGEST_URL}/v1/traces/recent?limit=5" "200" +check_read "Recent traces API" "${INGEST_URL}/v1/traces/recent?limit=5" "200" # Test 8: Trace story API (requires valid trace_id) if [[ -n "${trace_id:-}" ]]; then - check "Trace story API" "${INGEST_URL}/v1/traces/story?trace_id=${trace_id}" "200" + check_read "Trace story API" "${INGEST_URL}/v1/traces/story?trace_id=${trace_id}" "200" else echo "SKIP: Trace story API (no trace_id captured)" fi # Test 9: Trace story 404 for unknown trace -check "Trace story 404" "${INGEST_URL}/v1/traces/story?trace_id=00000000000000000000000000000000" "404" +check_read "Trace story 404" "${INGEST_URL}/v1/traces/story?trace_id=00000000000000000000000000000000" "404" # Test 10: Trace story 400 for missing param -check "Trace story 400" "${INGEST_URL}/v1/traces/story" "400" +check_read "Trace story 400" "${INGEST_URL}/v1/traces/story" "400" echo "" echo "=== Results: $passed passed, $failed failed ===" diff --git a/scripts/micro-demo-stop.sh b/scripts/micro-demo-stop.sh index 5d4afbf..37773bb 100755 --- a/scripts/micro-demo-stop.sh +++ b/scripts/micro-demo-stop.sh @@ -1,14 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -# Stop Kafka (if running via compose) -docker compose -f docker-compose.kafka.yml down -v >/dev/null 2>&1 || true - -# Kill micro-demo processes +# Kill v2 micro-demo processes. pkill -f "go run ./examples/cmd/api-gateway" >/dev/null 2>&1 || true pkill -f "go run ./examples/cmd/checkout-demo" >/dev/null 2>&1 || true pkill -f "go run ./examples/cmd/payment-demo" >/dev/null 2>&1 || true pkill -f "go run ./examples/cmd/db-demo" >/dev/null 2>&1 || true -pkill -f "go run ./cmd/bridge" >/dev/null 2>&1 || true pkill -f "go run ./cmd/ingest" >/dev/null 2>&1 || true pkill -f "go run ./cmd/waylog-live" >/dev/null 2>&1 || true diff --git a/scripts/micro-demo.sh b/scripts/micro-demo.sh index 001e79a..8ac28d3 100755 --- a/scripts/micro-demo.sh +++ b/scripts/micro-demo.sh @@ -4,67 +4,77 @@ set -euo pipefail GOCACHE_DIR="${GOCACHE:-/tmp/go-build}" export GOCACHE="$GOCACHE_DIR" -export KAFKA_BROKERS="${KAFKA_BROKERS:-localhost:9092}" -export KAFKA_TOPIC="${KAFKA_TOPIC:-wide_events}" +# v2 demo path: Kafka and cmd/bridge are intentionally unused here. +unset KAFKA_BROKERS + export INGEST_ADDR="${INGEST_ADDR:-:8080}" -export HAPPY_SAMPLE_RATE_PCT="${HAPPY_SAMPLE_RATE_PCT:-100}" +export INGEST_URL="${INGEST_URL:-http://localhost:8080}" +export WAYLOG_WRITE_KEY="${WAYLOG_WRITE_KEY:-demo}" +export WAYLOG_READ_KEY="${WAYLOG_READ_KEY:-demo}" +export DASHBOARD_AUTH="${DASHBOARD_AUTH:-key:demo}" +export WAYLOG_V2_READS="${WAYLOG_V2_READS:-true}" +export EVENT_LOG_V2_DIR="${EVENT_LOG_V2_DIR:-./data/eventlog-v2-demo}" pids=() cleanup() { for pid in "${pids[@]:-}"; do kill "$pid" >/dev/null 2>&1 || true done - - if [[ "${START_KAFKA:-}" == "1" ]]; then - docker compose -f docker-compose.kafka.yml down -v >/dev/null 2>&1 || true - fi } trap cleanup EXIT - -handle_interrupt() { - exit 0 +trap 'exit 0' INT + +wait_for_http() { + local url="$1" + local name="$2" + for _ in $(seq 1 60); do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + done + echo "Timed out waiting for ${name} at ${url}" >&2 + exit 1 } -trap handle_interrupt INT - -if [[ "${START_KAFKA:-}" == "1" ]]; then - docker compose -f docker-compose.kafka.yml up -d -fi -# Start payment-demo -GOCACHE="$GOCACHE_DIR" go run ./examples/cmd/payment-demo & +GOCACHE="$GOCACHE_DIR" go run ./cmd/ingest 2> ingest.log & pids+=("$!") -# Start db-demo +wait_for_http "${INGEST_URL}/readyz" "ingest" + GOCACHE="$GOCACHE_DIR" go run ./examples/cmd/db-demo & pids+=("$!") -# Start checkout-demo +GOCACHE="$GOCACHE_DIR" go run ./examples/cmd/payment-demo & +pids+=("$!") + GOCACHE="$GOCACHE_DIR" go run ./examples/cmd/checkout-demo & pids+=("$!") -# Start api-gateway GOCACHE="$GOCACHE_DIR" go run ./examples/cmd/api-gateway & pids+=("$!") -# Start Kafka -> ingest bridge -GOCACHE="$GOCACHE_DIR" go run ./cmd/bridge & -pids+=("$!") +wait_for_http "http://localhost:9081/demo" "api-gateway" cat < checkout; checkout does db then payment). +Schema-2.0 micro-demo running. - Gateway UI: http://localhost:9081/demo - Gateway API: http://localhost:9081/purchase +- Ingest API: ${INGEST_URL} -Use the UI to send requests, then query with: - waylog "show top errors" - waylog "trace summary for trace " +Trigger the main incident: + curl -s -X POST http://localhost:9081/purchase \\ + -H 'Content-Type: application/json' \\ + --data '{"sku":"X1","scenario":"payment_502"}' -To launch the TUI dashboard: - make waylog-live +Investigate with the v2 CLI: + WAYLOG_READ_KEY=${WAYLOG_READ_KEY} ./waylog errors --window 15m + WAYLOG_READ_KEY=${WAYLOG_READ_KEY} ./waylog explain + WAYLOG_READ_KEY=${WAYLOG_READ_KEY} ./waylog blast --service checkout --step payment.charge --code PMT_502 --window 15m + WAYLOG_READ_KEY=${WAYLOG_READ_KEY} ./waylog blast --code PMT_502 --window 15m Press Ctrl+C to stop everything. INFO -# Run ingest in foreground -GOCACHE="$GOCACHE_DIR" go run ./cmd/ingest 2> ingest.log +wait 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..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 *ingest.Server, 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 +}