commit b6b092c912b3e04295b21b9a3b90ad0a8e21814b Author: John Coffey Date: Thu Aug 13 08:25:19 2026 -0700 Scaffold Phase 0: agent -> Redpanda -> ingest -> ClickHouse -> api -> web End-to-end log pipeline for Linux hosts, per /docs/architecture.md: - proto: shared gRPC contract (agent <-> ingest), Go bindings checked in - agent: Rust, musl-targeted, journald/file sourcing, RFC5424 parser, mTLS gRPC client, no required config for the common case - ingest: Go, single binary with --mode server|consumer|all; gRPC front end forwards to Redpanda unchanged, consumer normalizes and batch-writes to ClickHouse with at-least-once delivery - storage: ClickHouse schema + a plain SQL-file migration runner - api: minimal SELECT-only query endpoint, plain REST (not gRPC+gateway yet -- see api/README.md) - web: SvelteKit static SPA, one query page - transport: Redpanda compose + topic provisioning - cli: sentryctl ping stub - hack/dev-certs: throwaway CA + cert generation for local mTLS - root docker-compose.yml + docs/phase-0-runbook.md tie it together Not yet run end-to-end against real Docker/ClickHouse/Redpanda -- see the runbook's caveats section before relying on this working as-is. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b6d1cda --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# ingest/ and api/ build with context "." (repo root) so their Dockerfiles +# can also COPY proto/. Keep that context lean. +.git/ +agent/target/ +web/node_modules/ +web/build/ +web/.svelte-kit/ +hack/dev-certs/out/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec986ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Rust +agent/target/ + +# Go build cache (go build ./... without -o doesn't normally leave +# binaries in-tree, but be defensive) +/ingest/ingest +/api/api +/cli/sentryctl + +# Node / SvelteKit (web/ has its own more detailed .gitignore too) +web/node_modules/ +web/build/ +web/.svelte-kit/ + +# Dev-only generated secrets +hack/dev-certs/out/ + +# OS / editor +.DS_Store +Thumbs.db +*.swp diff --git a/agent/Cargo.lock b/agent/Cargo.lock new file mode 100644 index 0000000..b293676 --- /dev/null +++ b/agent/Cargo.lock @@ -0,0 +1,1510 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "sentry-agent" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "prost", + "sentry-parser", + "serde", + "serde_json", + "tokio", + "toml", + "tonic", + "tonic-build", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sentry-parser" +version = "0.1.0" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "rustls-pemfile", + "socket2 0.5.10", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/agent/Cargo.toml b/agent/Cargo.toml new file mode 100644 index 0000000..48b649f --- /dev/null +++ b/agent/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] +resolver = "2" +members = ["sentry-parser", "sentry-agent"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "AGPL-3.0-only" + +[profile.release] +lto = true +strip = true +codegen-units = 1 diff --git a/agent/Dockerfile b/agent/Dockerfile new file mode 100644 index 0000000..f40a1da --- /dev/null +++ b/agent/Dockerfile @@ -0,0 +1,16 @@ +# Build context must be the repo root (sentry/), not agent/, since this +# needs both agent/ and proto/: +# docker build -f agent/Dockerfile -t sentry-agent . + +FROM rust:1-alpine AS builder +RUN apk add --no-cache musl-dev protobuf-dev protobuf +WORKDIR /src +COPY proto ./proto +COPY agent ./agent +WORKDIR /src/agent +RUN rustup target add x86_64-unknown-linux-musl \ + && cargo build --release --target x86_64-unknown-linux-musl -p sentry-agent + +FROM scratch +COPY --from=builder /src/agent/target/x86_64-unknown-linux-musl/release/sentry-agent /sentry-agent +ENTRYPOINT ["/sentry-agent"] diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..cdc7a5f --- /dev/null +++ b/agent/README.md @@ -0,0 +1,100 @@ +# sentry-agent + +Distro-agnostic Linux log collector. Statically linked against musl, no +glibc runtime dependency. Tails journald (default) or a file, batches +lines, and ships them over mTLS gRPC to the ingest service. + +## Workspace layout + +- `sentry-parser` — pure-`std` RFC 5424 syslog parser with raw-passthrough + fallback. No I/O, easy to unit test in isolation. +- `sentry-agent` — the binary: config loading, sourcing (journald/file), + batching, mTLS gRPC client. + +## Why journalctl, not libsystemd + +The journald source shells out to `journalctl -f -o json` rather than +linking `libsystemd` via FFI. Statically linking libsystemd into a musl +binary is fragile — it pulls in dbus/libcap transitively and isn't designed +for static linking — and would undermine the no-glibc-runtime-deps goal +even where technically possible. `journalctl` ships on every systemd distro +this agent targets, so shelling out sidesteps the problem entirely. See +`/docs/architecture.md`. + +## Building + +Native build (whatever target your machine is): + +```sh +cargo build --release +``` + +musl targets (what actually ships): + +```sh +rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl + +# x86_64: works with musl-gcc installed locally (musl-tools on Debian, +# musl on Arch, etc.) — the musl target is fully static by default. +cargo build --release --target x86_64-unknown-linux-musl + +# aarch64 cross-compilation needs a cross toolchain; the boring, reliable +# option is `cross` (https://github.com/cross-rs/cross), which builds +# inside a Docker container with the right linker preinstalled: +cross build --release --target aarch64-unknown-linux-musl +``` + +Building requires `protoc` on PATH (used by `tonic-build`/`prost-build` at +compile time to generate the gRPC client from `/proto/sentry/logs/v1/logs.proto`). + +Container build (see caveat below): + +```sh +# from the repo root, not agent/ +docker build -f agent/Dockerfile -t sentry-agent . +``` + +**Caveat:** the container image is provided for CI/completeness, but +journald sourcing needs `journalctl` and access to the host journal — +neither of which exist in the `scratch` image or are available to a +container without deliberately bind-mounting `/var/log/journal` (or +`/run/log/journal`) and the `journalctl` binary in. The intended Phase 0 +deployment for journald sourcing is as a native binary managed by systemd +on the host, not containerized. + +## Running + +No CLI flags are required for the common case: + +```sh +./sentry-agent +``` + +This uses `/etc/sentry-agent/agent.toml` if present, otherwise built-in +defaults: journald source (whole journal, no unit filter), service name +`default`, and mTLS material expected at +`/etc/sentry-agent/{ca,client,client-key}.pem`. mTLS is mandatory per the +project's transport requirements, so a from-scratch run with no certs in +place will fail fast with a clear error rather than connecting insecurely. + +See `config/agent.example.toml` for all fields. + +```sh +./sentry-agent --config /path/to/agent.toml +``` + +## Testing + +```sh +cargo test --workspace +``` + +## Feature flags + +- `journald` (default) — journalctl-based journald source. +- `file-tail` — polling-based file tailer (no inotify dependency; doesn't + follow rename-based log rotation yet). + +Both can be enabled together; `[source].kind` in config picks which one +runs. Building without a feature and configuring that source at runtime +fails at startup with a clear error rather than silently doing nothing. diff --git a/agent/rust-toolchain.toml b/agent/rust-toolchain.toml new file mode 100644 index 0000000..402d51c --- /dev/null +++ b/agent/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +targets = ["x86_64-unknown-linux-musl", "aarch64-unknown-linux-musl"] diff --git a/agent/sentry-agent/Cargo.toml b/agent/sentry-agent/Cargo.toml new file mode 100644 index 0000000..ace6482 --- /dev/null +++ b/agent/sentry-agent/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "sentry-agent" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Sentry distro-agnostic Linux log collector" + +[[bin]] +name = "sentry-agent" +path = "src/main.rs" + +[features] +default = ["journald"] +journald = [] +file-tail = [] + +[dependencies] +sentry-parser = { path = "../sentry-parser" } + +tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "io-std", "time", "fs", "sync", "signal"] } +tonic = { version = "0.12", features = ["tls"] } +prost = "0.13" + +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" + +clap = { version = "4", features = ["derive"] } +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[build-dependencies] +tonic-build = "0.12" diff --git a/agent/sentry-agent/build.rs b/agent/sentry-agent/build.rs new file mode 100644 index 0000000..06cc559 --- /dev/null +++ b/agent/sentry-agent/build.rs @@ -0,0 +1,9 @@ +fn main() -> Result<(), Box> { + tonic_build::configure() + .build_server(false) + .compile_protos( + &["../../proto/sentry/logs/v1/logs.proto"], + &["../../proto"], + )?; + Ok(()) +} diff --git a/agent/sentry-agent/config/agent.example.toml b/agent/sentry-agent/config/agent.example.toml new file mode 100644 index 0000000..d0013db --- /dev/null +++ b/agent/sentry-agent/config/agent.example.toml @@ -0,0 +1,33 @@ +# Example sentry-agent config. Copy to /etc/sentry-agent/agent.toml, or +# pass --config /path/to/this/file. +# +# Every field has a built-in default (see src/config.rs), so this file only +# needs to contain what you're overriding. An agent with NO config file at +# all still runs: it defaults to journald, service = "default", and expects +# mTLS material at /etc/sentry-agent/{ca,client,client-key}.pem. + +[agent] +# host = "explicit-hostname-override" # defaults to /etc/hostname +service = "my-service" + +[source] +kind = "journald" +# unit = "nginx.service" # omit to tail the whole journal + +# To tail a file instead: +# [source] +# kind = "file" +# path = "/var/log/nginx/access.log" +# from_beginning = false + +[batch] +max_size = 500 +flush_interval_ms = 2000 + +[ingest] +endpoint = "https://ingest.internal:4317" + +[tls] +ca_cert = "/etc/sentry-agent/ca.pem" +client_cert = "/etc/sentry-agent/client.pem" +client_key = "/etc/sentry-agent/client-key.pem" diff --git a/agent/sentry-agent/src/batch.rs b/agent/sentry-agent/src/batch.rs new file mode 100644 index 0000000..0db36f6 --- /dev/null +++ b/agent/sentry-agent/src/batch.rs @@ -0,0 +1,108 @@ +use crate::pb::LogRecord; +use std::time::{Duration, Instant}; + +/// Buffers `LogRecord`s and signals when to flush, either because the +/// buffer hit `max_size` (checked on every push) or because +/// `flush_interval` elapsed since the last flush (checked by the caller via +/// `poll_timeout` on a timer tick). Not thread-safe by design — one +/// batcher per agent, driven from a single async task's select loop. +pub struct Batcher { + max_size: usize, + flush_interval: Duration, + buf: Vec, + last_flush: Instant, +} + +impl Batcher { + pub fn new(max_size: usize, flush_interval: Duration) -> Self { + Self { + max_size, + flush_interval, + buf: Vec::with_capacity(max_size), + last_flush: Instant::now(), + } + } + + /// Push a record. Returns the drained batch if this push filled the + /// buffer to `max_size`. + pub fn push(&mut self, record: LogRecord) -> Option> { + self.buf.push(record); + if self.buf.len() >= self.max_size { + Some(self.drain()) + } else { + None + } + } + + /// Call periodically (e.g. from a timer tick). Returns the drained + /// batch if the flush interval has elapsed and there's anything + /// buffered. + pub fn poll_timeout(&mut self) -> Option> { + if !self.buf.is_empty() && self.last_flush.elapsed() >= self.flush_interval { + Some(self.drain()) + } else { + None + } + } + + fn drain(&mut self) -> Vec { + self.last_flush = Instant::now(); + std::mem::replace(&mut self.buf, Vec::with_capacity(self.max_size)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rec(msg: &str) -> LogRecord { + LogRecord { + timestamp_unix_nano: 0, + host: "h".into(), + service: "s".into(), + severity: 0, + message: msg.into(), + attributes: Default::default(), + } + } + + #[test] + fn flushes_on_size() { + let mut b = Batcher::new(2, Duration::from_secs(999)); + assert!(b.push(rec("a")).is_none()); + let batch = b.push(rec("b")).expect("should flush at max_size"); + assert_eq!(batch.len(), 2); + assert_eq!(batch[0].message, "a"); + assert_eq!(batch[1].message, "b"); + } + + #[test] + fn buffer_empty_after_size_flush() { + let mut b = Batcher::new(1, Duration::from_secs(999)); + b.push(rec("a")).expect("flush at max_size 1"); + assert!(b.poll_timeout().is_none(), "buffer should be empty post-flush"); + } + + #[test] + fn flushes_on_timeout() { + let mut b = Batcher::new(100, Duration::from_millis(10)); + assert!(b.push(rec("a")).is_none()); + std::thread::sleep(Duration::from_millis(30)); + let batch = b.poll_timeout().expect("should flush after timeout"); + assert_eq!(batch.len(), 1); + } + + #[test] + fn no_flush_when_buffer_empty() { + let mut b = Batcher::new(10, Duration::from_millis(1)); + std::thread::sleep(Duration::from_millis(5)); + assert!(b.poll_timeout().is_none()); + } + + #[test] + fn no_flush_before_timeout_elapsed() { + let mut b = Batcher::new(10, Duration::from_secs(999)); + b.push(rec("a")); + assert!(b.poll_timeout().is_none()); + } +} diff --git a/agent/sentry-agent/src/config.rs b/agent/sentry-agent/src/config.rs new file mode 100644 index 0000000..9f0e40c --- /dev/null +++ b/agent/sentry-agent/src/config.rs @@ -0,0 +1,127 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +const DEFAULT_CONFIG_PATH: &str = "/etc/sentry-agent/agent.toml"; + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +pub struct Config { + pub agent: AgentConfig, + pub source: SourceConfig, + pub batch: BatchConfig, + pub ingest: IngestConfig, + pub tls: TlsConfig, +} + +impl Config { + /// Loads config from `explicit_path` if given, else from + /// `/etc/sentry-agent/agent.toml` if it exists, else falls back to + /// built-in defaults (journald source, default TLS cert paths). Only an + /// explicitly-passed `--config` path that doesn't exist is an error; + /// the conventional default path is optional. + pub fn load(explicit_path: Option<&Path>) -> Result { + let path = match explicit_path { + Some(p) => Some(p.to_path_buf()), + None => { + let default = PathBuf::from(DEFAULT_CONFIG_PATH); + default.exists().then_some(default) + } + }; + + match path { + Some(p) => { + let raw = std::fs::read_to_string(&p) + .with_context(|| format!("reading config file {}", p.display()))?; + toml::from_str(&raw).with_context(|| format!("parsing config file {}", p.display())) + } + None => Ok(Config::default()), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct AgentConfig { + /// Overrides the auto-detected system hostname. Defaults to reading + /// /etc/hostname at startup when unset. + pub host: Option, + pub service: String, +} + +impl Default for AgentConfig { + fn default() -> Self { + Self { + host: None, + service: "default".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "lowercase", tag = "kind")] +pub enum SourceConfig { + Journald { + #[serde(default)] + unit: Option, + }, + File { + path: PathBuf, + #[serde(default)] + from_beginning: bool, + }, +} + +impl Default for SourceConfig { + fn default() -> Self { + SourceConfig::Journald { unit: None } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct BatchConfig { + pub max_size: usize, + pub flush_interval_ms: u64, +} + +impl Default for BatchConfig { + fn default() -> Self { + Self { + max_size: 500, + flush_interval_ms: 2000, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct IngestConfig { + pub endpoint: String, +} + +impl Default for IngestConfig { + fn default() -> Self { + Self { + endpoint: "https://127.0.0.1:4317".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct TlsConfig { + pub ca_cert: PathBuf, + pub client_cert: PathBuf, + pub client_key: PathBuf, +} + +impl Default for TlsConfig { + fn default() -> Self { + Self { + ca_cert: PathBuf::from("/etc/sentry-agent/ca.pem"), + client_cert: PathBuf::from("/etc/sentry-agent/client.pem"), + client_key: PathBuf::from("/etc/sentry-agent/client-key.pem"), + } + } +} diff --git a/agent/sentry-agent/src/grpc.rs b/agent/sentry-agent/src/grpc.rs new file mode 100644 index 0000000..d2195bb --- /dev/null +++ b/agent/sentry-agent/src/grpc.rs @@ -0,0 +1,45 @@ +use crate::config::{IngestConfig, TlsConfig}; +use crate::pb::{log_ingest_client::LogIngestClient, LogRecord, PushBatchRequest}; +use anyhow::{Context, Result}; +use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity}; + +/// Establishes an mTLS gRPC channel to the ingest service. Agents never +/// talk to Redpanda directly — this is the only network egress the agent +/// has, by design (see /docs/architecture.md). +pub async fn connect(ingest: &IngestConfig, tls: &TlsConfig) -> Result> { + let ca = tokio::fs::read(&tls.ca_cert) + .await + .with_context(|| format!("reading CA cert at {}", tls.ca_cert.display()))?; + let cert = tokio::fs::read(&tls.client_cert) + .await + .with_context(|| format!("reading client cert at {}", tls.client_cert.display()))?; + let key = tokio::fs::read(&tls.client_key) + .await + .with_context(|| format!("reading client key at {}", tls.client_key.display()))?; + + let tls_config = ClientTlsConfig::new() + .ca_certificate(Certificate::from_pem(ca)) + .identity(Identity::from_pem(cert, key)); + + let channel = Channel::from_shared(ingest.endpoint.clone()) + .context("invalid ingest endpoint URL")? + .tls_config(tls_config) + .context("configuring mTLS")? + .connect() + .await + .context("connecting to ingest service")?; + + Ok(LogIngestClient::new(channel)) +} + +pub async fn send_batch( + client: &mut LogIngestClient, + batch_id: String, + records: Vec, +) -> Result { + let resp = client + .push_batch(PushBatchRequest { batch_id, records }) + .await + .context("PushBatch RPC failed")?; + Ok(resp.into_inner().accepted) +} diff --git a/agent/sentry-agent/src/main.rs b/agent/sentry-agent/src/main.rs new file mode 100644 index 0000000..2347712 --- /dev/null +++ b/agent/sentry-agent/src/main.rs @@ -0,0 +1,153 @@ +mod batch; +mod config; +mod grpc; +mod source; + +pub mod pb { + tonic::include_proto!("sentry.logs.v1"); +} + +use anyhow::{Context, Result}; +use batch::Batcher; +use clap::Parser; +use config::Config; +use pb::{log_ingest_client::LogIngestClient, LogRecord, Severity}; +use std::path::PathBuf; +use std::time::Duration; +use tokio::sync::mpsc; +use tonic::transport::Channel; + +#[derive(Parser)] +#[command(name = "sentry-agent", about = "Sentry Linux log collector")] +struct Cli { + /// Path to a TOML config file. Defaults to /etc/sentry-agent/agent.toml + /// if present, otherwise built-in defaults (journald source, default + /// TLS cert paths under /etc/sentry-agent/). + #[arg(long)] + config: Option, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let cli = Cli::parse(); + let cfg = Config::load(cli.config.as_deref()).context("loading config")?; + + let host = cfg.agent.host.clone().unwrap_or_else(default_hostname); + let service = cfg.agent.service.clone(); + + let (tx, mut rx) = mpsc::channel(1024); + let source_handle = tokio::spawn(spawn_source(cfg.source.clone(), tx)); + + let mut client = grpc::connect(&cfg.ingest, &cfg.tls) + .await + .context("connecting to ingest service")?; + tracing::info!(endpoint = %cfg.ingest.endpoint, "connected to ingest service"); + + let flush_interval = Duration::from_millis(cfg.batch.flush_interval_ms); + let mut batcher = Batcher::new(cfg.batch.max_size, flush_interval); + let mut ticker = tokio::time::interval(flush_interval.max(Duration::from_millis(50))); + + loop { + tokio::select! { + maybe_line = rx.recv() => { + let Some(raw) = maybe_line else { + tracing::warn!("source exited, flushing remaining batch and shutting down"); + break; + }; + let parsed = sentry_parser::parse(&raw.line); + let severity = to_pb_severity(raw.severity_hint.or(parsed.severity)); + let record = LogRecord { + timestamp_unix_nano: raw.timestamp_unix_nano, + host: host.clone(), + service: service.clone(), + severity: severity as i32, + message: parsed.message, + attributes: parsed.attributes.into_iter().collect(), + }; + if let Some(batch) = batcher.push(record) { + flush(&mut client, batch).await; + } + } + _ = ticker.tick() => { + if let Some(batch) = batcher.poll_timeout() { + flush(&mut client, batch).await; + } + } + } + } + + if let Some(batch) = batcher.poll_timeout() { + flush(&mut client, batch).await; + } + source_handle.abort(); + Ok(()) +} + +async fn spawn_source(source: config::SourceConfig, tx: source::LineSender) { + let result = match source { + #[cfg(feature = "journald")] + config::SourceConfig::Journald { unit } => source::journald::run(unit.as_deref(), tx).await, + #[cfg(not(feature = "journald"))] + config::SourceConfig::Journald { .. } => { + Err(anyhow::anyhow!("this build was compiled without the `journald` feature")) + } + + #[cfg(feature = "file-tail")] + config::SourceConfig::File { path, from_beginning } => { + source::file_tail::run(&path, from_beginning, tx).await + } + #[cfg(not(feature = "file-tail"))] + config::SourceConfig::File { .. } => { + Err(anyhow::anyhow!("this build was compiled without the `file-tail` feature")) + } + }; + if let Err(e) = result { + tracing::error!(error = %e, "log source exited with error"); + } +} + +async fn flush(client: &mut LogIngestClient, batch: Vec) { + let n = batch.len(); + let batch_id = batch_id(); + match grpc::send_batch(client, batch_id, batch).await { + Ok(accepted) => tracing::debug!(accepted, sent = n, "batch flushed"), + Err(e) => tracing::error!(error = %e, sent = n, "batch flush failed"), + } +} + +/// Best-effort batch identifier for ingest-side dedup on retry. Not +/// globally unique (host + nanosecond timestamp), which is good enough for +/// Phase 0's single-agent-per-host reality; revisit if agents ever share +/// an identity or clock resolution becomes a problem. +fn batch_id() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{nanos:x}") +} + +fn to_pb_severity(sev: Option) -> Severity { + match sev { + Some(0..=2) => Severity::Fatal, // emerg / alert / crit + Some(3) => Severity::Error, // err + Some(4) => Severity::Warn, // warning + Some(5) | Some(6) => Severity::Info, // notice / info + Some(7) => Severity::Debug, // debug + _ => Severity::Unspecified, + } +} + +fn default_hostname() -> String { + if let Ok(s) = std::fs::read_to_string("/etc/hostname") { + let s = s.trim().to_string(); + if !s.is_empty() { + return s; + } + } + std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown-host".to_string()) +} diff --git a/agent/sentry-agent/src/source/file_tail.rs b/agent/sentry-agent/src/source/file_tail.rs new file mode 100644 index 0000000..f4378a6 --- /dev/null +++ b/agent/sentry-agent/src/source/file_tail.rs @@ -0,0 +1,73 @@ +use super::{LineSender, RawLine}; +use anyhow::{Context, Result}; +use std::io::SeekFrom; +use std::path::Path; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::fs::File; +use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader}; + +const POLL_INTERVAL: Duration = Duration::from_millis(500); + +/// Polling-based file tailer: no inotify/`notify` crate dependency. Good +/// enough for Phase 0 (journald is the primary source). Handles basic +/// truncation (e.g. logrotate `copytruncate`) by detecting the file shrank +/// and reopening from the start. Does not follow rename-based rotation +/// (logrotate `create`) — that's deferred until file-tail is more than a +/// fallback path. +pub async fn run(path: &Path, from_beginning: bool, tx: LineSender) -> Result<()> { + let file = File::open(path) + .await + .with_context(|| format!("opening {}", path.display()))?; + + let mut pos = if from_beginning { 0 } else { file.metadata().await?.len() }; + + let mut reader = BufReader::new(file); + reader.seek(SeekFrom::Start(pos)).await?; + let mut buf = String::new(); + + loop { + buf.clear(); + let n = reader + .read_line(&mut buf) + .await + .context("reading line from file")?; + if n == 0 { + let metadata = tokio::fs::metadata(path).await.context("stat-ing file")?; + if metadata.len() < pos { + tracing::warn!(path = %path.display(), "file shrank, assuming truncation and reopening from start"); + let f = File::open(path) + .await + .context("reopening file after truncation")?; + reader = BufReader::new(f); + pos = 0; + } + tokio::time::sleep(POLL_INTERVAL).await; + continue; + } + pos += n as u64; + + let line = buf.trim_end_matches(['\n', '\r']).to_string(); + if line.is_empty() { + continue; + } + + let timestamp_unix_nano = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as i64) + .unwrap_or(0); + + if tx + .send(RawLine { + line, + timestamp_unix_nano, + severity_hint: None, + }) + .await + .is_err() + { + break; + } + } + + Ok(()) +} diff --git a/agent/sentry-agent/src/source/journald.rs b/agent/sentry-agent/src/source/journald.rs new file mode 100644 index 0000000..d988845 --- /dev/null +++ b/agent/sentry-agent/src/source/journald.rs @@ -0,0 +1,75 @@ +use super::{LineSender, RawLine}; +use anyhow::{Context, Result}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; + +/// Reads journald entries by shelling out to `journalctl -f -o json` +/// rather than linking libsystemd via FFI. Linking libsystemd into a +/// statically-linked musl binary is fragile (it pulls in dbus/libcap +/// transitively and isn't designed for static linking) and would work +/// against the no-glibc-runtime-deps constraint in spirit even where it's +/// technically possible. `journalctl` ships on every systemd distro this +/// agent targets, so shelling out avoids the problem entirely. See +/// /docs/architecture.md. +pub async fn run(unit: Option<&str>, tx: LineSender) -> Result<()> { + let mut cmd = Command::new("journalctl"); + cmd.arg("-f") + .arg("-o") + .arg("json") + .arg("--since=now") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()); + if let Some(unit) = unit { + cmd.arg("-u").arg(unit); + } + + let mut child = cmd + .spawn() + .context("spawning journalctl -f -o json (is systemd-journal installed?)")?; + let stdout = child.stdout.take().context("journalctl child had no stdout")?; + let mut lines = BufReader::new(stdout).lines(); + + while let Some(line) = lines.next_line().await.context("reading journalctl output")? { + let Ok(entry) = serde_json::from_str::(&line) else { + tracing::warn!(%line, "skipping unparseable journalctl JSON line"); + continue; + }; + + let message = entry + .get("MESSAGE") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + if message.is_empty() { + continue; + } + + let severity_hint = entry + .get("PRIORITY") + .and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_u64().map(|n| n.to_string()))) + .and_then(|s| s.parse::().ok()) + .filter(|&p| p <= 7); + + let timestamp_unix_nano = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as i64) + .unwrap_or(0); + + if tx + .send(RawLine { + line: message, + timestamp_unix_nano, + severity_hint, + }) + .await + .is_err() + { + break; // receiver dropped, agent is shutting down + } + } + + let status = child.wait().await.context("waiting for journalctl to exit")?; + tracing::warn!(?status, "journalctl exited"); + Ok(()) +} diff --git a/agent/sentry-agent/src/source/mod.rs b/agent/sentry-agent/src/source/mod.rs new file mode 100644 index 0000000..ae52b84 --- /dev/null +++ b/agent/sentry-agent/src/source/mod.rs @@ -0,0 +1,23 @@ +use tokio::sync::mpsc; + +/// A raw line read from a source, plus whatever metadata the source itself +/// already knows before the RFC 5424 parser ever sees it. +#[derive(Debug, Clone)] +pub struct RawLine { + pub line: String, + /// Unix epoch nanoseconds at time of read. + pub timestamp_unix_nano: i64, + /// Syslog severity (0-7) if the source already knows it independent of + /// the line's own content — e.g. journald's PRIORITY field. When set, + /// this takes precedence over whatever the RFC 5424 parser infers from + /// the message text, since it comes from a more authoritative place. + pub severity_hint: Option, +} + +pub type LineSender = mpsc::Sender; + +#[cfg(feature = "journald")] +pub mod journald; + +#[cfg(feature = "file-tail")] +pub mod file_tail; diff --git a/agent/sentry-parser/Cargo.toml b/agent/sentry-parser/Cargo.toml new file mode 100644 index 0000000..2976226 --- /dev/null +++ b/agent/sentry-parser/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "sentry-parser" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Minimal RFC 5424 syslog parser with raw-passthrough fallback" diff --git a/agent/sentry-parser/src/lib.rs b/agent/sentry-parser/src/lib.rs new file mode 100644 index 0000000..dcb6e47 --- /dev/null +++ b/agent/sentry-parser/src/lib.rs @@ -0,0 +1,285 @@ +//! Minimal RFC 5424 syslog parser with raw-passthrough fallback. +//! +//! This is intentionally not a complete RFC 5424 implementation (no BOM +//! handling on MSG, "-" nil markers are kept as literal strings rather than +//! mapped to `None`). It's the Phase 0 minimum: parse what's clearly +//! structured syslog, and never fail a log line outright — anything that +//! doesn't match the grammar becomes a raw passthrough record instead of +//! being dropped. + +use std::collections::BTreeMap; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedLine { + /// Syslog facility (0-23), present only when RFC 5424 framing parsed. + pub facility: Option, + /// Syslog severity (0=emergency .. 7=debug), present only when RFC 5424 + /// framing parsed. + pub severity: Option, + /// Structured fields extracted from the PRI/HEADER/STRUCTURED-DATA + /// portions. Empty when the raw-passthrough fallback fires. + pub attributes: BTreeMap, + /// The MSG portion when RFC 5424 parsing succeeded, otherwise the + /// original, unmodified line. + pub message: String, +} + +/// Parse a single log line. Never fails: falls back to a raw passthrough +/// `ParsedLine` (no facility/severity, empty attributes, message = input) +/// when the line doesn't match RFC 5424 framing. +pub fn parse(line: &str) -> ParsedLine { + parse_rfc5424(line).unwrap_or_else(|| ParsedLine { + facility: None, + severity: None, + attributes: BTreeMap::new(), + message: line.to_string(), + }) +} + +struct Scanner<'a> { + chars: std::iter::Peekable>, +} + +impl<'a> Scanner<'a> { + fn new(s: &'a str) -> Self { + Scanner { + chars: s.chars().peekable(), + } + } + + fn peek(&mut self) -> Option { + self.chars.peek().copied() + } + + fn next(&mut self) -> Option { + self.chars.next() + } + + fn expect(&mut self, c: char) -> Option<()> { + if self.next()? == c { + Some(()) + } else { + None + } + } + + fn take_while bool>(&mut self, f: F) -> String { + let mut out = String::new(); + while let Some(c) = self.peek() { + if f(c) { + out.push(c); + self.next(); + } else { + break; + } + } + out + } + + fn skip_one_space(&mut self) -> Option<()> { + self.expect(' ') + } +} + +fn parse_rfc5424(line: &str) -> Option { + let mut sc = Scanner::new(line); + + sc.expect('<')?; + let pri_str = sc.take_while(|c| c.is_ascii_digit()); + if pri_str.is_empty() || pri_str.len() > 3 { + return None; + } + sc.expect('>')?; + let pri: u16 = pri_str.parse().ok()?; + if pri > 191 { + return None; + } + let facility = (pri / 8) as u8; + let severity = (pri % 8) as u8; + + let version = sc.take_while(|c| c.is_ascii_digit()); + if version.is_empty() { + return None; + } + sc.skip_one_space()?; + + let timestamp = sc.take_while(|c| c != ' '); + if timestamp.is_empty() { + return None; + } + sc.skip_one_space()?; + + let hostname = sc.take_while(|c| c != ' '); + if hostname.is_empty() { + return None; + } + sc.skip_one_space()?; + + let app_name = sc.take_while(|c| c != ' '); + if app_name.is_empty() { + return None; + } + sc.skip_one_space()?; + + let procid = sc.take_while(|c| c != ' '); + if procid.is_empty() { + return None; + } + sc.skip_one_space()?; + + let msgid = sc.take_while(|c| c != ' '); + if msgid.is_empty() { + return None; + } + sc.skip_one_space()?; + + let mut sd_pairs: Vec<(String, String, String)> = Vec::new(); + match sc.peek() { + Some('-') => { + sc.next(); + } + Some('[') => loop { + if sc.peek() != Some('[') { + break; + } + sc.next(); + let sd_id = sc.take_while(|c| c != ' ' && c != ']'); + if sd_id.is_empty() { + return None; + } + loop { + match sc.peek() { + Some(' ') => { + sc.next(); + let name = sc.take_while(|c| c != '='); + sc.expect('=')?; + sc.expect('"')?; + let mut val = String::new(); + loop { + match sc.next() { + Some('\\') => val.push(sc.next()?), + Some('"') => break, + Some(c) => val.push(c), + None => return None, + } + } + sd_pairs.push((sd_id.clone(), name, val)); + } + Some(']') => { + sc.next(); + break; + } + _ => return None, + } + } + }, + _ => return None, + } + + let message = if sc.peek() == Some(' ') { + sc.next(); + sc.take_while(|_| true) + } else { + String::new() + }; + + let mut attributes = BTreeMap::new(); + attributes.insert("syslog.version".to_string(), version); + attributes.insert("syslog.timestamp".to_string(), timestamp); + attributes.insert("syslog.hostname".to_string(), hostname); + attributes.insert("syslog.app_name".to_string(), app_name); + attributes.insert("syslog.procid".to_string(), procid); + attributes.insert("syslog.msgid".to_string(), msgid); + for (sd_id, name, val) in sd_pairs { + attributes.insert(format!("{sd_id}.{name}"), val); + } + + Some(ParsedLine { + facility: Some(facility), + severity: Some(severity), + attributes, + message, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_full_rfc5424_with_structured_data() { + let line = r#"<165>1 2003-10-11T22:14:15.003Z mymachine.example.com evntslp - ID47 [exampleSDID@32473 iut="3" eventSource="Application" eventID="1011"] An application event log entry"#; + let p = parse(line); + assert_eq!(p.facility, Some(20)); + assert_eq!(p.severity, Some(5)); + assert_eq!(p.message, "An application event log entry"); + assert_eq!( + p.attributes.get("exampleSDID@32473.iut"), + Some(&"3".to_string()) + ); + assert_eq!( + p.attributes.get("exampleSDID@32473.eventSource"), + Some(&"Application".to_string()) + ); + assert_eq!( + p.attributes.get("syslog.hostname"), + Some(&"mymachine.example.com".to_string()) + ); + } + + #[test] + fn parses_nil_structured_data_and_fields() { + let line = "<34>1 2003-10-11T22:14:15.003Z mymachine su - ID47 - 'su root' failed"; + let p = parse(line); + assert_eq!(p.facility, Some(4)); + assert_eq!(p.severity, Some(2)); + assert_eq!(p.attributes.get("syslog.procid"), Some(&"-".to_string())); + assert_eq!(p.message, "'su root' failed"); + } + + #[test] + fn parses_multiple_structured_data_elements() { + let line = r#"<165>1 2003-10-11T22:14:15.003Z host app - ID47 [a@1 k="v"][b@1 k2="v2"] msg"#; + let p = parse(line); + assert_eq!(p.attributes.get("a@1.k"), Some(&"v".to_string())); + assert_eq!(p.attributes.get("b@1.k2"), Some(&"v2".to_string())); + assert_eq!(p.message, "msg"); + } + + #[test] + fn handles_escaped_quote_in_param_value() { + let line = r#"<165>1 2003-10-11T22:14:15.003Z host app - ID47 [a@1 k="has \"quote\" inside"] msg"#; + let p = parse(line); + assert_eq!( + p.attributes.get("a@1.k"), + Some(&"has \"quote\" inside".to_string()) + ); + } + + #[test] + fn falls_back_to_raw_passthrough_for_non_syslog_line() { + let line = "this is just a plain log line, not syslog at all"; + let p = parse(line); + assert_eq!(p.facility, None); + assert_eq!(p.severity, None); + assert!(p.attributes.is_empty()); + assert_eq!(p.message, line); + } + + #[test] + fn falls_back_to_raw_passthrough_for_malformed_pri() { + let line = "1 2003-10-11T22:14:15.003Z host app - ID47 - msg"; + let p = parse(line); + assert_eq!(p.facility, None); + assert_eq!(p.message, line); + } + + #[test] + fn falls_back_when_structured_data_missing() { + // Missing the required "-" or "[...]" for STRUCTURED-DATA. + let line = "<34>1 2003-10-11T22:14:15.003Z host app 123 ID47"; + let p = parse(line); + assert_eq!(p.facility, None); + assert_eq!(p.message, line); + } +} diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..7ead31f --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,13 @@ +# Build context must be the repo root (sentry/): +# docker build -f api/Dockerfile -t sentry-api . + +FROM golang:1.25-alpine AS builder +WORKDIR /src +COPY api ./api +WORKDIR /src/api +RUN go mod download +RUN CGO_ENABLED=0 GOOS=linux go build -o /out/api ./cmd/api + +FROM gcr.io/distroless/static-debian12 +COPY --from=builder /out/api /api +ENTRYPOINT ["/api"] diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..68f44b8 --- /dev/null +++ b/api/README.md @@ -0,0 +1,63 @@ +# api + +Sentry's Phase 0 query API: one crude, intentionally placeholder endpoint. + +## Why plain REST, not gRPC + REST gateway + +CLAUDE.md pins the control plane to "Go, gRPC + REST gateway." This +service is plain `net/http` instead — a deliberate Phase 0 simplification, +not a change to the pinned stack. Wiring up a `.proto` service, +`google.api.http` annotations, and `protoc-gen-grpc-gateway` codegen for a +single endpoint that Phase 2 replaces outright with a real SPL-like query +layer would be exactly the kind of premature machinery this project's +conventions warn against. Adopt the gRPC+gateway pattern once `/api` grows +a second real, durable endpoint. + +## Endpoints + +- `POST /query` — body `{"sql": "SELECT ..."}`, response + `{"columns": [...], "rows": [[...], ...]}` or `{"error": "..."}`. + SELECT-only, single-statement, basic keyword-based injection guarding + (see `internal/queryapi/validate.go` for exactly what that does and + doesn't catch — it's not a SQL parser). +- `GET /healthz` — for docker-compose/k8s liveness checks. + +No auth. Not scoped for Phase 0 — don't expose this beyond a trusted +dev/homelab network. + +## Configuration + +Environment variables (see `internal/config/config.go`): + +| Var | Default | Purpose | +|---|---|---| +| `HTTP_LISTEN_ADDR` | `:8080` | | +| `CLICKHOUSE_ADDR` | `localhost:9000` | Native protocol port | +| `CLICKHOUSE_DATABASE` / `_USERNAME` / `_PASSWORD` | `sentry` / `default` / `` | | +| `QUERY_TIMEOUT_SECONDS` | `30` | Per-request ClickHouse query timeout | +| `CORS_ALLOWED_ORIGIN` | `*` | Wide open by default since there's no auth yet; tighten together | + +## Building & testing + +```sh +go build ./... +go vet ./... +go test ./... +``` + +```sh +# from the repo root, not api/ +docker build -f api/Dockerfile -t sentry-api . +``` + +## Testing notes + +`internal/queryapi`'s HTTP handler depends on ClickHouse only through a +one-method `queryExecutor` interface, so routing, validation, JSON +encoding, and error-status mapping are all unit-tested against a fake — +no live ClickHouse needed. `Executor` itself (the reflection-based row +scanning against `driver.Rows`) is not unit-tested — faking ClickHouse's +`driver.Rows` interface fully would be significant test-only scaffolding +for a Phase 0 placeholder, and the driver package's own docs note it isn't +meant to be implemented by adopters. It's exercised end-to-end via the +docker-compose flow in `/docs/phase-0-runbook.md` instead. diff --git a/api/cmd/api/main.go b/api/cmd/api/main.go new file mode 100644 index 0000000..779c10c --- /dev/null +++ b/api/cmd/api/main.go @@ -0,0 +1,80 @@ +// Command api is the Sentry Phase 0 query API: a single crude POST /query +// endpoint proxying allowlisted SELECT statements to ClickHouse. See +// internal/queryapi for why this is plain REST rather than the pinned +// gRPC+gateway pattern for Phase 0. +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/ClickHouse/clickhouse-go/v2" + + "github.com/sentry/sentry/api/internal/config" + "github.com/sentry/sentry/api/internal/queryapi" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + cfg, err := config.Load() + if err != nil { + logger.Error("loading config", "error", err) + os.Exit(1) + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + conn, err := clickhouse.Open(&clickhouse.Options{ + Addr: []string{cfg.ClickHouse.Addr}, + Auth: clickhouse.Auth{ + Database: cfg.ClickHouse.Database, + Username: cfg.ClickHouse.Username, + Password: cfg.ClickHouse.Password, + }, + }) + if err != nil { + logger.Error("opening clickhouse connection", "error", err) + os.Exit(1) + } + defer conn.Close() + + if err := conn.Ping(ctx); err != nil { + logger.Error("pinging clickhouse", "error", err) + os.Exit(1) + } + + exec := queryapi.NewExecutor(conn) + handler := queryapi.NewHandler(logger, exec, cfg.QueryTimeout, cfg.CORSAllowedOrigin) + + srv := &http.Server{ + Addr: cfg.HTTPListenAddr, + Handler: handler.Routes(), + } + + errCh := make(chan error, 1) + go func() { + logger.Info("api listening", "addr", cfg.HTTPListenAddr) + errCh <- srv.ListenAndServe() + }() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + logger.Error("graceful shutdown failed", "error", err) + } + case err := <-errCh: + if err != nil && err != http.ErrServerClosed { + logger.Error("server exited with error", "error", err) + os.Exit(1) + } + } +} diff --git a/api/go.mod b/api/go.mod new file mode 100644 index 0000000..731774b --- /dev/null +++ b/api/go.mod @@ -0,0 +1,22 @@ +module github.com/sentry/sentry/api + +go 1.25.0 + +require github.com/ClickHouse/clickhouse-go/v2 v2.48.0 + +require ( + github.com/ClickHouse/ch-go v0.74.0 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.19.1 // indirect + github.com/paulmach/orb v0.13.0 // indirect + github.com/pierrec/lz4/v4 v4.1.27 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/api/go.sum b/api/go.sum new file mode 100644 index 0000000..6bf06f1 --- /dev/null +++ b/api/go.sum @@ -0,0 +1,42 @@ +github.com/ClickHouse/ch-go v0.74.0 h1:uYs2m4wIt0ZHSM1E72rg0maCfzhR2V3xWb/vZEgpeWE= +github.com/ClickHouse/ch-go v0.74.0/go.mod h1:sZ/r+8ttZMjyrP9PuFbgoVbth1ywIu2LIQNA2vgko6M= +github.com/ClickHouse/clickhouse-go/v2 v2.48.0 h1:auzd4VkapQYhQF8F2Gog7s3x78Bi1JZmByxGbrw3C+4= +github.com/ClickHouse/clickhouse-go/v2 v2.48.0/go.mod h1:lBjUCPRG6RpRQdMbkXq+JV8rY0/O5lw+Z7jShgReFjM= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw= +github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k= +github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= +github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/api/internal/config/config.go b/api/internal/config/config.go new file mode 100644 index 0000000..1408fa5 --- /dev/null +++ b/api/internal/config/config.go @@ -0,0 +1,56 @@ +// Package config loads api's configuration from environment variables, +// same convention as /ingest: no config file format for Phase 0. +package config + +import ( + "fmt" + "os" + "strconv" + "time" +) + +type Config struct { + HTTPListenAddr string + ClickHouse ClickHouseConfig + QueryTimeout time.Duration + CORSAllowedOrigin string +} + +type ClickHouseConfig struct { + Addr string + Database string + Username string + Password string +} + +func Load() (Config, error) { + cfg := Config{ + HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8080"), + ClickHouse: ClickHouseConfig{ + Addr: getenv("CLICKHOUSE_ADDR", "localhost:9000"), + Database: getenv("CLICKHOUSE_DATABASE", "sentry"), + Username: getenv("CLICKHOUSE_USERNAME", "default"), + Password: getenv("CLICKHOUSE_PASSWORD", ""), + }, + // Phase 0 has no auth, so this is wide open by default to keep + // the local SvelteKit dev server (a different origin/port) + // working out of the box. Tighten before this is ever reachable + // from outside a trusted dev/homelab network. + CORSAllowedOrigin: getenv("CORS_ALLOWED_ORIGIN", "*"), + } + + timeoutSec, err := strconv.Atoi(getenv("QUERY_TIMEOUT_SECONDS", "30")) + if err != nil { + return Config{}, fmt.Errorf("QUERY_TIMEOUT_SECONDS: %w", err) + } + cfg.QueryTimeout = time.Duration(timeoutSec) * time.Second + + return cfg, nil +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/api/internal/config/config_test.go b/api/internal/config/config_test.go new file mode 100644 index 0000000..17d6885 --- /dev/null +++ b/api/internal/config/config_test.go @@ -0,0 +1,29 @@ +package config + +import ( + "testing" + "time" +) + +func TestLoadDefaults(t *testing.T) { + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.HTTPListenAddr != ":8080" { + t.Errorf("HTTPListenAddr = %q, want :8080", cfg.HTTPListenAddr) + } + if cfg.QueryTimeout != 30*time.Second { + t.Errorf("QueryTimeout = %v, want 30s", cfg.QueryTimeout) + } + if cfg.CORSAllowedOrigin != "*" { + t.Errorf("CORSAllowedOrigin = %q, want *", cfg.CORSAllowedOrigin) + } +} + +func TestLoadInvalidTimeoutErrors(t *testing.T) { + t.Setenv("QUERY_TIMEOUT_SECONDS", "not-a-number") + if _, err := Load(); err == nil { + t.Fatal("expected error for non-numeric QUERY_TIMEOUT_SECONDS, got nil") + } +} diff --git a/api/internal/queryapi/executor.go b/api/internal/queryapi/executor.go new file mode 100644 index 0000000..28cc6c7 --- /dev/null +++ b/api/internal/queryapi/executor.go @@ -0,0 +1,60 @@ +package queryapi + +import ( + "context" + "fmt" + "reflect" + + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" +) + +type QueryResult struct { + Columns []string `json:"columns"` + Rows [][]any `json:"rows"` +} + +// Executor runs arbitrary (pre-validated) SELECT statements against +// ClickHouse and shapes the result into JSON-friendly columns/rows, +// discovering the result's column set at query time via reflection since +// the query itself is arbitrary. +type Executor struct { + conn driver.Conn +} + +func NewExecutor(conn driver.Conn) *Executor { + return &Executor{conn: conn} +} + +func (e *Executor) Execute(ctx context.Context, sql string) (*QueryResult, error) { + rows, err := e.conn.Query(ctx, sql) + if err != nil { + return nil, fmt.Errorf("executing query: %w", err) + } + defer rows.Close() + + columnTypes := rows.ColumnTypes() + result := &QueryResult{ + Columns: rows.Columns(), + Rows: [][]any{}, + } + + for rows.Next() { + dest := make([]any, len(columnTypes)) + for i, ct := range columnTypes { + dest[i] = reflect.New(ct.ScanType()).Interface() + } + if err := rows.Scan(dest...); err != nil { + return nil, fmt.Errorf("scanning row: %w", err) + } + row := make([]any, len(dest)) + for i, d := range dest { + row[i] = reflect.ValueOf(d).Elem().Interface() + } + result.Rows = append(result.Rows, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating rows: %w", err) + } + + return result, nil +} diff --git a/api/internal/queryapi/handler.go b/api/internal/queryapi/handler.go new file mode 100644 index 0000000..60355e2 --- /dev/null +++ b/api/internal/queryapi/handler.go @@ -0,0 +1,112 @@ +// Package queryapi is the Phase 0 query API: a single crude POST /query +// endpoint that takes a raw SQL string, allowlists it to a single SELECT +// statement, and proxies it to ClickHouse. This is a deliberate +// simplification of the pinned "gRPC + REST gateway" control-plane +// pattern (see CLAUDE.md's tech stack table): a plain net/http REST +// handler, not a gRPC service transcoded through grpc-gateway. That +// machinery (proto definitions, googleapis annotations, gateway codegen) +// buys nothing for one crude placeholder endpoint that Phase 2 replaces +// outright with the real SPL-like query layer. Revisit gRPC+gateway when +// /api grows a second real endpoint. +package queryapi + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "time" +) + +// queryExecutor is the narrow interface handleQuery depends on, so tests +// can substitute a fake without a real ClickHouse connection. *Executor +// satisfies it. +type queryExecutor interface { + Execute(ctx context.Context, sql string) (*QueryResult, error) +} + +type Handler struct { + logger *slog.Logger + exec queryExecutor + queryTimeout time.Duration + allowedOrigin string +} + +func NewHandler(logger *slog.Logger, exec queryExecutor, queryTimeout time.Duration, allowedOrigin string) *Handler { + return &Handler{logger: logger, exec: exec, queryTimeout: queryTimeout, allowedOrigin: allowedOrigin} +} + +func (h *Handler) Routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /query", h.handleQuery) + mux.HandleFunc("GET /healthz", h.handleHealthz) + return h.withCORS(mux) +} + +// withCORS is deliberately permissive by default (see CORSAllowedOrigin in +// internal/config) since Phase 0 has no auth and the SvelteKit dev server +// runs on a different origin. Tighten alongside adding real auth. +func (h *Handler) withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", h.allowedOrigin) + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func (h *Handler) handleHealthz(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) +} + +type queryRequest struct { + SQL string `json:"sql"` +} + +type errorResponse struct { + Error string `json:"error"` +} + +// maxBodyBytes caps the request body: a raw SQL string has no legitimate +// reason to be larger than this. +const maxBodyBytes = 1 << 20 // 1 MiB + +func (h *Handler) handleQuery(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + + var req queryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) + return + } + + if err := validateSelectOnly(req.SQL); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), h.queryTimeout) + defer cancel() + + result, err := h.exec.Execute(ctx, req.SQL) + if err != nil { + h.logger.Error("query execution failed", "error", err) + writeError(w, http.StatusBadGateway, "query failed: "+err.Error()) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(result); err != nil { + h.logger.Error("encoding response", "error", err) + } +} + +func writeError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(errorResponse{Error: msg}) +} diff --git a/api/internal/queryapi/handler_test.go b/api/internal/queryapi/handler_test.go new file mode 100644 index 0000000..399c007 --- /dev/null +++ b/api/internal/queryapi/handler_test.go @@ -0,0 +1,134 @@ +package queryapi + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type fakeExecutor struct { + result *QueryResult + err error + gotSQL string +} + +func (f *fakeExecutor) Execute(_ context.Context, sql string) (*QueryResult, error) { + f.gotSQL = sql + if f.err != nil { + return nil, f.err + } + return f.result, nil +} + +func newTestHandler(exec queryExecutor) *Handler { + return NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), exec, time.Second, "*") +} + +func TestHandleQuerySuccess(t *testing.T) { + fe := &fakeExecutor{result: &QueryResult{ + Columns: []string{"host", "count"}, + Rows: [][]any{{"h1", 3}}, + }} + h := newTestHandler(fe) + + body := strings.NewReader(`{"sql": "SELECT host, count(*) FROM logs GROUP BY host"}`) + req := httptest.NewRequest(http.MethodPost, "/query", body) + rec := httptest.NewRecorder() + + h.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var got QueryResult + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decoding response: %v", err) + } + if len(got.Columns) != 2 || len(got.Rows) != 1 { + t.Fatalf("unexpected result: %+v", got) + } + if fe.gotSQL != "SELECT host, count(*) FROM logs GROUP BY host" { + t.Fatalf("executor received unexpected SQL: %q", fe.gotSQL) + } +} + +func TestHandleQueryRejectsNonSelect(t *testing.T) { + fe := &fakeExecutor{} + h := newTestHandler(fe) + + body := strings.NewReader(`{"sql": "DELETE FROM logs"}`) + req := httptest.NewRequest(http.MethodPost, "/query", body) + rec := httptest.NewRecorder() + + h.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if fe.gotSQL != "" { + t.Fatal("executor should not have been called for a rejected query") + } +} + +func TestHandleQueryRejectsInvalidJSON(t *testing.T) { + h := newTestHandler(&fakeExecutor{}) + + body := strings.NewReader(`not json`) + req := httptest.NewRequest(http.MethodPost, "/query", body) + rec := httptest.NewRecorder() + + h.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestHandleQueryExecutorErrorReturnsBadGateway(t *testing.T) { + fe := &fakeExecutor{err: errors.New("boom")} + h := newTestHandler(fe) + + body := strings.NewReader(`{"sql": "SELECT 1"}`) + req := httptest.NewRequest(http.MethodPost, "/query", body) + rec := httptest.NewRecorder() + + h.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502", rec.Code) + } +} + +func TestHandleHealthz(t *testing.T) { + h := newTestHandler(&fakeExecutor{}) + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + + h.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } +} + +func TestCORSPreflight(t *testing.T) { + h := newTestHandler(&fakeExecutor{}) + req := httptest.NewRequest(http.MethodOptions, "/query", nil) + rec := httptest.NewRecorder() + + h.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Fatalf("Access-Control-Allow-Origin = %q, want *", got) + } +} diff --git a/api/internal/queryapi/validate.go b/api/internal/queryapi/validate.go new file mode 100644 index 0000000..4649e30 --- /dev/null +++ b/api/internal/queryapi/validate.go @@ -0,0 +1,47 @@ +package queryapi + +import ( + "errors" + "regexp" + "strings" +) + +// disallowedKeyword is defense-in-depth on top of the SELECT-only gate: it +// catches mutating/administrative statements appearing anywhere in the +// query (e.g. smuggled into a subquery), not just at the start. This is +// word-boundary matching, not a real SQL parser. +var disallowedKeyword = regexp.MustCompile(`(?i)\b(insert|update|delete|alter|drop|truncate|create|grant|revoke|attach|detach|rename|kill|optimize|system|set|exchange|watch)\b`) + +// validateSelectOnly enforces the Phase 0 query API contract: exactly one +// SELECT statement and nothing else. This is "basic injection guarding" as +// specced, not a SQL parser: it will reject some unusual-but-valid SELECTs +// (e.g. one that references a column literally named "delete") and will +// not catch every possible abuse (e.g. a syntactically pure SELECT that's +// simply expensive to run). Both are acceptable for a Phase 0 placeholder +// that's explicitly superseded by a real query layer in Phase 2 — see +// /docs/architecture.md. +func validateSelectOnly(sql string) error { + trimmed := strings.TrimSpace(sql) + if trimmed == "" { + return errors.New("query must not be empty") + } + + trimmed = strings.TrimSpace(strings.TrimSuffix(trimmed, ";")) + if trimmed == "" { + return errors.New("query must not be empty") + } + if strings.Contains(trimmed, ";") { + return errors.New("only a single statement is allowed") + } + + firstWord := strings.ToUpper(strings.Fields(trimmed)[0]) + if firstWord != "SELECT" { + return errors.New("only SELECT queries are allowed") + } + + if disallowedKeyword.MatchString(trimmed) { + return errors.New("query contains a disallowed keyword") + } + + return nil +} diff --git a/api/internal/queryapi/validate_test.go b/api/internal/queryapi/validate_test.go new file mode 100644 index 0000000..5999f23 --- /dev/null +++ b/api/internal/queryapi/validate_test.go @@ -0,0 +1,38 @@ +package queryapi + +import "testing" + +func TestValidateSelectOnly(t *testing.T) { + cases := []struct { + name string + sql string + wantErr bool + }{ + {"plain select", "SELECT * FROM logs LIMIT 10", false}, + {"lowercase select", "select service, count(*) from logs group by service", false}, + {"trailing semicolon allowed", "SELECT 1;", false}, + {"trailing semicolon and whitespace allowed", "SELECT 1; ", false}, + {"empty", "", true}, + {"whitespace only", " ", true}, + {"only a semicolon", ";", true}, + {"multiple statements", "SELECT 1; SELECT 2", true}, + {"insert", "INSERT INTO logs VALUES (1)", true}, + {"delete", "DELETE FROM logs", true}, + {"drop", "DROP TABLE logs", true}, + {"select with drop keyword smuggled in", "SELECT * FROM logs WHERE message = 'DROP TABLE logs'", true}, + {"non-select start", "WITH x AS (SELECT 1) SELECT * FROM x", true}, + {"trailing garbage after semicolon", "SELECT 1; DROP TABLE logs", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateSelectOnly(tc.sql) + if tc.wantErr && err == nil { + t.Errorf("validateSelectOnly(%q) = nil, want error", tc.sql) + } + if !tc.wantErr && err != nil { + t.Errorf("validateSelectOnly(%q) = %v, want nil", tc.sql, err) + } + }) + } +} diff --git a/cli/Dockerfile b/cli/Dockerfile new file mode 100644 index 0000000..5f71417 --- /dev/null +++ b/cli/Dockerfile @@ -0,0 +1,9 @@ +# docker build -f cli/Dockerfile -t sentryctl cli/ +FROM golang:1.25-alpine AS builder +WORKDIR /src +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /out/sentryctl ./cmd/sentryctl + +FROM gcr.io/distroless/static-debian12 +COPY --from=builder /out/sentryctl /sentryctl +ENTRYPOINT ["/sentryctl"] diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..415e27b --- /dev/null +++ b/cli/README.md @@ -0,0 +1,28 @@ +# sentryctl + +Sentry's control CLI. Phase 0: a single command. + +```sh +sentryctl ping # checks http://localhost:8080/healthz +sentryctl ping --api http://api.internal:8080 +SENTRYCTL_API_URL=http://api.internal:8080 sentryctl ping +``` + +Exits 0 and prints `ok` if `/api`'s `/healthz` responds 200; exits 1 with an +error on `stderr` otherwise. + +No CLI framework (cobra/urfave-cli/etc.) — a single command doesn't need +one, and stdlib `os.Args` handling is boring enough not to need a +dependency. Revisit once there's a real command tree to justify one. + +## Building & testing + +```sh +go build ./... +go vet ./... +go test ./... +``` + +```sh +docker build -f Dockerfile -t sentryctl . # context is cli/, not the repo root +``` diff --git a/cli/cmd/sentryctl/main.go b/cli/cmd/sentryctl/main.go new file mode 100644 index 0000000..c6c3615 --- /dev/null +++ b/cli/cmd/sentryctl/main.go @@ -0,0 +1,87 @@ +// Command sentryctl is Sentry's control CLI. Phase 0: a single "ping" +// command that checks the api service is reachable. More commands land as +// the control plane grows real operations to expose. +package main + +import ( + "fmt" + "io" + "net/http" + "os" + "time" +) + +const defaultAPIURL = "http://localhost:8080" + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + usage(stderr) + return 1 + } + + switch args[0] { + case "ping": + return cmdPing(args[1:], stdout, stderr) + case "-h", "--help", "help": + usage(stdout) + return 0 + default: + fmt.Fprintf(stderr, "sentryctl: unknown command %q\n", args[0]) + usage(stderr) + return 1 + } +} + +func usage(w io.Writer) { + fmt.Fprintln(w, `sentryctl: Sentry control CLI (Phase 0: ping only) + +Usage: + sentryctl ping [--api ] + +Commands: + ping Checks that the api service is reachable via GET /healthz. + +--api defaults to $SENTRYCTL_API_URL, or `+defaultAPIURL+` if unset.`) +} + +// parsePingArgs resolves the api base URL for ping: --api flag wins, then +// $SENTRYCTL_API_URL, then the hardcoded default. Kept pure (env passed in +// as a function) and separate from the HTTP call so it's unit-testable +// without a real environment or server. +func parsePingArgs(args []string, env func(string) string) string { + apiURL := env("SENTRYCTL_API_URL") + if apiURL == "" { + apiURL = defaultAPIURL + } + for i := 0; i < len(args); i++ { + if args[i] == "--api" && i+1 < len(args) { + apiURL = args[i+1] + i++ + } + } + return apiURL +} + +func cmdPing(args []string, stdout, stderr io.Writer) int { + apiURL := parsePingArgs(args, os.Getenv) + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(apiURL + "/healthz") + if err != nil { + fmt.Fprintf(stderr, "ping failed: %v\n", err) + return 1 + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(stderr, "ping failed: api returned status %d\n", resp.StatusCode) + return 1 + } + + fmt.Fprintln(stdout, "ok") + return 0 +} diff --git a/cli/cmd/sentryctl/main_test.go b/cli/cmd/sentryctl/main_test.go new file mode 100644 index 0000000..bf7d1ee --- /dev/null +++ b/cli/cmd/sentryctl/main_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestParsePingArgsDefault(t *testing.T) { + env := func(string) string { return "" } + if got := parsePingArgs(nil, env); got != defaultAPIURL { + t.Errorf("got %q, want %q", got, defaultAPIURL) + } +} + +func TestParsePingArgsFromEnv(t *testing.T) { + env := func(k string) string { + if k == "SENTRYCTL_API_URL" { + return "http://env-host:1234" + } + return "" + } + if got := parsePingArgs(nil, env); got != "http://env-host:1234" { + t.Errorf("got %q, want env value", got) + } +} + +func TestParsePingArgsFlagOverridesEnv(t *testing.T) { + env := func(k string) string { + if k == "SENTRYCTL_API_URL" { + return "http://env-host:1234" + } + return "" + } + got := parsePingArgs([]string{"--api", "http://flag-host:5678"}, env) + if got != "http://flag-host:5678" { + t.Errorf("got %q, want flag value", got) + } +} + +func TestCmdPingSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/healthz" { + t.Errorf("unexpected path %q", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := cmdPing([]string{"--api", srv.URL}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr=%s", code, stderr.String()) + } + if strings.TrimSpace(stdout.String()) != "ok" { + t.Fatalf("stdout = %q, want ok", stdout.String()) + } +} + +func TestCmdPingNonOKStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := cmdPing([]string{"--api", srv.URL}, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "503") { + t.Fatalf("stderr = %q, want it to mention the status code", stderr.String()) + } +} + +func TestCmdPingUnreachable(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cmdPing([]string{"--api", "http://127.0.0.1:1"}, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } +} + +func TestRunNoArgsPrintsUsageAndFails(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run(nil, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "Usage:") { + t.Fatalf("stderr should contain usage text, got %q", stderr.String()) + } +} + +func TestRunUnknownCommand(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"bogus"}, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "bogus") { + t.Fatalf("stderr should mention the unknown command, got %q", stderr.String()) + } +} + +func TestRunHelp(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"help"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("exit code = %d, want 0", code) + } + if !strings.Contains(stdout.String(), "Usage:") { + t.Fatalf("stdout should contain usage text, got %q", stdout.String()) + } +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..ea464ef --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,3 @@ +module github.com/sentry/sentry/cli + +go 1.25 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..118bd18 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,128 @@ +# Phase 0 stack: Redpanda -> ingest -> ClickHouse -> api -> web. +# +# Does NOT include the Rust agent — see /agent/README.md: journald +# sourcing needs the host's journal, which isn't something a container +# gets for free. Run the agent natively on the host per +# /docs/phase-0-runbook.md, pointed at ingest's mapped port (localhost:4317). +# +# Before first run: generate dev mTLS certs (hack/dev-certs/generate.sh). +# See /docs/phase-0-runbook.md for the full sequence. +services: + redpanda: + image: docker.redpanda.com/redpandadata/redpanda:v24.2.7 + container_name: sentry-redpanda + command: + - redpanda + - start + - --smp=1 + - --memory=1G + - --reserve-memory=0M + - --overprovisioned + - --node-id=0 + - --check=false + - --kafka-addr=PLAINTEXT://0.0.0.0:9092 + - --advertise-kafka-addr=PLAINTEXT://redpanda:9092 + ports: + - "9092:9092" + volumes: + - redpanda-data:/var/lib/redpanda/data + healthcheck: + test: ["CMD", "rpk", "cluster", "health", "--exit-when-healthy"] + interval: 5s + timeout: 5s + retries: 30 + + # One-shot: creates the sentry.logs.raw topic, then exits 0. ingest + # waits on this completing successfully before it starts. + redpanda-provision: + build: + context: ./transport + container_name: sentry-redpanda-provision + depends_on: + redpanda: + condition: service_healthy + environment: + REDPANDA_BROKERS: "redpanda:9092" + + clickhouse: + image: clickhouse/clickhouse-server:24.8 + container_name: sentry-clickhouse + ports: + - "8123:8123" # HTTP interface, used by the migrate step + - "9000:9000" # native protocol, used by ingest and api + volumes: + - clickhouse-data:/var/lib/clickhouse + ulimits: + nofile: + soft: 262144 + hard: 262144 + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8123/ping"] + interval: 5s + timeout: 5s + retries: 30 + + # One-shot: applies /storage/migrations/*.sql, then exits 0. ingest and + # api both wait on this completing successfully. + clickhouse-migrate: + build: + context: ./storage + container_name: sentry-clickhouse-migrate + depends_on: + clickhouse: + condition: service_healthy + environment: + CLICKHOUSE_HTTP: "http://clickhouse:8123" + + ingest: + build: + context: . # needs both ingest/ and proto/ + dockerfile: ingest/Dockerfile + container_name: sentry-ingest + depends_on: + redpanda-provision: + condition: service_completed_successfully + clickhouse-migrate: + condition: service_completed_successfully + ports: + - "4317:4317" # gRPC, mTLS — this is what the host-run agent connects to + environment: + REDPANDA_BROKERS: "redpanda:9092" + CLICKHOUSE_ADDR: "clickhouse:9000" + # TLS_*_FILE env vars are left at their defaults + # (/etc/sentry-ingest/{server,server-key,ca}.pem) — matches where + # the volume below mounts the generated dev certs. + volumes: + - ./hack/dev-certs/out:/etc/sentry-ingest:ro + + api: + build: + context: . + dockerfile: api/Dockerfile + container_name: sentry-api + depends_on: + clickhouse-migrate: + condition: service_completed_successfully + ports: + - "8080:8080" + environment: + CLICKHOUSE_ADDR: "clickhouse:9000" + + web: + build: + context: web + args: + # Baked in at build time (static site, not a server) as + # localhost:8080 -- this is fetched from the *browser*, which + # resolves against the host's mapped port, not the compose + # network's service DNS name. + VITE_API_BASE_URL: "http://localhost:8080" + container_name: sentry-web + depends_on: + - api + ports: + - "3000:3000" + +volumes: + redpanda-data: + clickhouse-data: diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d132c1e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,105 @@ +# Sentry Architecture + +> **Status:** Draft, Phase 0 scope. Written from the project constraints and +> task list at kickoff, not transcribed from a pre-existing spec. Treat as a +> starting point to correct, not a settled design — flag anything that +> doesn't match your intent before implementation leans on it further. + +## Mission + +Open-core, Kubernetes-native centralized logging platform. Compete with +Splunk on features; win on cost-per-GB, a modern language stack, and +multi-tenant RBAC that's actually honest about its guarantees. + +## Component map + +``` +┌──────────┐ gRPC/mTLS ┌──────────┐ produce ┌───────────┐ consume ┌────────────┐ +│ agent │ ────────────▶ │ ingest │ ──────────▶ │ Redpanda │ ────────▶ │ ingest │ +│ (Rust) │ │ (Go) │ │ (Kafka API)│ │ consumer │ +└──────────┘ └──────────┘ └───────────┘ │ (Go) │ + └──────┬─────┘ + │ batch INSERT + ▼ + ┌────────────┐ + │ ClickHouse │ + └─────┬──────┘ + │ SQL + ┌───────────▼───────────┐ + │ api (Go: gRPC+REST) │ + └───────────┬───────────┘ + │ REST + ┌───────────▼───────────┐ + │ web (SvelteKit) │ + └────────────────────────┘ +``` + +Decision (confirmed 2026-08-12): Redpanda stays in the Phase 0 path. The +`ingest` service's gRPC front end produces to Redpanda rather than writing +ClickHouse directly; a separate consumer path reads from Redpanda and batches +inserts into ClickHouse. This exercises the real transport layer from day +one instead of deferring it, and keeps Kafka credentials off the edge agent. + +## Storage / query split + +- **ClickHouse** is the analytical store of record for structured log data: + timestamp, host, service, severity, message, plus a `Map(String,String)` + for arbitrary structured fields. Partitioned by day, ordered by + `(service, timestamp)`. +- **Tantivy** (Phase 1) will provide full-text indexing over the `message` + field and unstructured payloads, queried out-of-band from ClickHouse and + joined by a log identifier. Not built in Phase 0. +- **Schema-on-write** using OTel semantic conventions as the default log + schema; schema-on-read fallback for unstructured/raw text that doesn't fit + the structured columns (captured via the `Map` column and/or a raw + passthrough field). + +This split is not to be changed without discussion — see CLAUDE.md. + +## Component responsibilities (Phase 0) + +| Component | Responsibility | +|---|---| +| `agent` (Rust, musl) | Tail a log file or read journald; parse RFC 5424 syslog with raw passthrough fallback; batch; ship via gRPC/mTLS to `ingest`. | +| `proto` | Shared `.proto` contracts for the agent↔ingest gRPC service, versioned independently of either component. | +| `transport` | Redpanda docker-compose + topic provisioning scripts. No application code. | +| `ingest` (Go) | gRPC server accepting agent connections; produces normalized OTel-log-like records to Redpanda; separate consumer reads from Redpanda and batch-writes to ClickHouse. | +| `storage` | ClickHouse schema migrations + docker-compose for local/homelab. | +| `api` (Go) | gRPC + REST gateway. Phase 0: one crude `POST /query` endpoint, SELECT-only, proxying to ClickHouse. Real SPL-like query layer is Phase 2. | +| `web` (SvelteKit) | Single page: SQL text box, submit, results table. No auth, no styling polish. | +| `cli` (`sentryctl`) | Stub. Single `ping` command for now. | +| `deploy` | Helm charts, k8s manifests. Stubbed in Phase 0; docker-compose is the real local/dev path. | + +## Licensing boundary + +AGPLv3 for core + agents. Enterprise features (SSO, multi-tenancy, +compliance) live under `enterprise/` (not yet created — out of scope for +Phase 0) under a commercial license stub. AGPL code must never import from +`enterprise/`. No enterprise-gated code exists yet in this repo; this +section documents the boundary so nothing added later crosses it by +accident. + +## Non-negotiables carried from CLAUDE.md + +- Rust agent: statically linked musl, `x86_64-unknown-linux-musl` and + `aarch64-unknown-linux-musl`, no glibc runtime deps. +- Windows support (Phase 1+) via native ETW/Event Log API, not WSL. +- Every UI action maps to a documented REST/gRPC call — no UI-only logic. +- Pinned stack (see CLAUDE.md table) — no substitutions without discussion. + +## Explicitly out of scope for Phase 0 + +Windows agent, alerting, dashboards, multi-tenancy, Tantivy full-text +search, the real SPL-like query language, enterprise module code. + +## Open questions for you to resolve + +- Retention/TTL policy for the ClickHouse `logs` table — not specified yet, + deferred until storage sizing is a real concern. +- Exact OTel log schema field mapping (which OTel resource/log attributes + map to which ClickHouse columns) — Phase 0 uses a minimal subset + (timestamp, host, service, severity, message, attributes map); full + mapping deferred. +- mTLS certificate provisioning/rotation story for agents — Phase 0 will use + a static dev CA and manually issued certs; production PKI design is + out of scope here. diff --git a/docs/phase-0-runbook.md b/docs/phase-0-runbook.md new file mode 100644 index 0000000..2e0f631 --- /dev/null +++ b/docs/phase-0-runbook.md @@ -0,0 +1,207 @@ +# Phase 0 runbook + +Walks one log line from a Linux host, through the Rust agent, Redpanda, +ingest, and ClickHouse, to a browser table. This is the actual +"done" criterion for Phase 0 — if this doesn't work, Phase 0 isn't done, +regardless of what any individual component's tests say. + +**This sequence has not been run end-to-end** in the environment that +built it (no Docker available there — see the caveats each component's +summary already flagged). Individual pieces are unit-tested and built +successfully in isolation; this document is the logical sequence to run +for real, not a report that it's been run. Expect to debug something on +first attempt, and treat the "Troubleshooting" section at the bottom as a +starting point, not an exhaustive list. + +## Prerequisites + +- Docker with **Compose v2** (`docker compose`, not the legacy + `docker-compose` v1 binary) — the compose file uses + `service_completed_successfully` conditions that v1 doesn't support. +- Rust toolchain (`cargo`) and `protoc` — to build the agent. +- `openssl` — to generate dev mTLS certs. +- A systemd-based Linux host to run the agent on (journald is the default + source). If you're not on such a host, see `/agent/README.md`'s + `file-tail` feature as an alternative source. + +You do **not** need the musl cross-compilation target for this runbook — +that's for producing the distro-agnostic release binary. A native +`cargo build --release` is enough to run the agent on the same machine +you're testing on. + +## 1. Generate dev mTLS certs + +```sh +./hack/dev-certs/generate.sh +``` + +Writes a throwaway CA plus a server cert (for `ingest`) and a client cert +(for the agent) to `hack/dev-certs/out/`. Dev-only — see the script's +header comment for why. + +## 2. Bring up the backend stack + +```sh +docker compose up -d --build +``` + +This builds and starts, in dependency order: `redpanda` → `redpanda-provision` +(creates the `sentry.logs.raw` topic, then exits) → `clickhouse` → +`clickhouse-migrate` (applies `/storage/migrations`, then exits) → +`ingest` and `api` → `web`. + +Check everything came up: + +```sh +docker compose ps +``` + +`redpanda-provision` and `clickhouse-migrate` should show `Exited (0)` +(one-shot jobs, not long-running). Everything else should show `Up` / +`healthy`. + +If `ingest` or `api` crash-looped, they likely started before their +`depends_on` conditions were actually satisfied, or the dev certs from +step 1 don't exist yet — check `docker compose logs ingest`. + +## 3. Sanity-check the backend before involving the agent + +```sh +curl http://localhost:8080/healthz +# -> 200, empty body + +curl -X POST http://localhost:8080/query \ + -H 'Content-Type: application/json' \ + -d '{"sql": "SELECT 1"}' +# -> {"columns":["1"],"rows":[[1]]} (exact column name may vary by ClickHouse version) +``` + +This confirms `api` can reach `clickhouse` before you go looking for bugs +anywhere else. It doesn't touch the `logs` table, so it works even before +any agent has sent data. + +## 4. Install the agent's mTLS material + +The agent's default config expects certs at `/etc/sentry-agent/` (see +`/agent/config/agent.example.toml`), which requires root: + +```sh +sudo mkdir -p /etc/sentry-agent +sudo cp hack/dev-certs/out/ca.pem \ + hack/dev-certs/out/client.pem \ + hack/dev-certs/out/client-key.pem \ + /etc/sentry-agent/ +``` + +## 5. Build and run the agent + +```sh +cd agent +cargo build --release +``` + +The agent's built-in defaults already match this setup with **zero +config file**: journald source (whole journal), service name `default`, +ingest endpoint `https://127.0.0.1:4317` (matches the port `ingest` +publishes in `docker-compose.yml`), and the cert paths from step 4. This +is the "no required flags for the common case" design goal from +`/agent/README.md` — if it doesn't just run, that design assumption is +wrong somewhere and worth reporting as a bug, not working around. + +Reading the system journal generally needs root (or membership in the +`systemd-journal` group with a distro that grants it read access — varies +by distro, root is the reliable path for this runbook): + +```sh +sudo ./target/release/sentry-agent +``` + +Leave it running in this terminal — you should see a `connected to ingest +service` log line. If you see a TLS or connection error instead, stop +here and check the Troubleshooting section before continuing. + +## 6. Generate a test log line + +In another terminal, **after** the agent is running and connected +(journald tailing starts from "now" — anything logged before the agent +started won't be picked up): + +```sh +logger "hello from sentry phase 0" +``` + +`logger` (part of util-linux, present on virtually every Linux distro) +writes this to the system log, which journald captures immediately. + +Give it a couple of seconds — the agent batches with a 2-second flush +interval by default, so the line won't hit ingest instantly. + +## 7. Confirm it's queryable + +**Via the web UI:** + +`web` is already running from step 2 (`docker compose up -d --build` +starts every service in the file). Open `http://localhost:3000`, run the +default query (`SELECT * FROM logs +ORDER BY timestamp DESC LIMIT 100`), and look for a row with +`message = "hello from sentry phase 0"`. + +**Or via curl, if you want to skip the browser:** + +```sh +curl -X POST http://localhost:8080/query \ + -H 'Content-Type: application/json' \ + -d '{"sql": "SELECT * FROM logs ORDER BY timestamp DESC LIMIT 10"}' +``` + +**Or via sentryctl, just to confirm api is up (doesn't check the data +itself):** + +```sh +cd cli && go run ./cmd/sentryctl ping +``` + +If you see the row: that's Phase 0 done, end to end. If you don't, see +Troubleshooting below. + +## Tearing down + +```sh +docker compose down # stops and removes containers, keeps volumes +docker compose down -v # also wipes Redpanda/ClickHouse data — start clean next time +``` + +## Troubleshooting + +**Agent logs a TLS/certificate error on startup.** +Check the server cert's SAN actually covers how the agent is connecting +(`openssl x509 -in hack/dev-certs/out/server.pem -noout -ext +subjectAltName` — should list `DNS:ingest, DNS:localhost, +IP:127.0.0.1`). If you changed the agent's `ingest.endpoint` to something +not in that list, regenerate certs with an updated SAN in +`hack/dev-certs/generate.sh`, don't disable TLS verification. + +**Agent connects but no data ever shows up in ClickHouse.** +Check each hop in order rather than guessing: +1. `docker compose logs ingest` — look for "batch produced to redpanda" + (gRPC front end got the batch) vs. errors. +2. `docker compose logs ingest` again — look for "batch flushed to + clickhouse" from the consumer half. If you see repeated "clickhouse + batch write failed... will redeliver" messages, `clickhouse-migrate` + likely hasn't finished (check `docker compose ps`) — the consumer will + keep retrying and self-heal once the table exists, per its + at-least-once design (see `/ingest/README.md`), so this may just need + more time rather than intervention. +3. `docker compose exec redpanda rpk topic list` — confirm + `sentry.logs.raw` exists (if `redpanda-provision` failed, it won't). + +**`docker compose up` fails on `service_completed_successfully`.** +You're likely on Compose v1 (`docker-compose`, hyphenated) rather than v2 +(`docker compose`, space) — see Prerequisites. + +**Web UI query returns an error instead of rows.** +Open the browser's network tab — if the request never leaves the page +(CORS error in the console), confirm `api`'s `CORS_ALLOWED_ORIGIN` +(defaults to `*`, should not be the issue) and that `VITE_API_BASE_URL` +was set correctly at `web`'s build time (it's baked in, not read at +container start — see `/web/README.md`). diff --git a/hack/README.md b/hack/README.md new file mode 100644 index 0000000..78776a9 --- /dev/null +++ b/hack/README.md @@ -0,0 +1,16 @@ +# hack + +Local developer tooling that isn't part of any shipped component — scripts +you run against your own machine/dev stack, not code that ends up in a +container image (except `dev-certs`' *output*, which mounts into the +ingest container). + +Not one of the top-level directories in the original monorepo scaffold — +added because dev-only mTLS cert generation didn't have a natural home in +`/deploy` (real deployment manifests), `/transport`, or any other existing +component. `/hack` is the conventional name for this in a lot of larger Go +monorepos (Kubernetes among them). + +- `dev-certs/` — generates a throwaway CA + server/client cert pair for + local mTLS between the agent and ingest. See `/docs/phase-0-runbook.md` + for when to run it. diff --git a/hack/dev-certs/.gitignore b/hack/dev-certs/.gitignore new file mode 100644 index 0000000..89f9ac0 --- /dev/null +++ b/hack/dev-certs/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/hack/dev-certs/generate.sh b/hack/dev-certs/generate.sh new file mode 100755 index 0000000..d5d594d --- /dev/null +++ b/hack/dev-certs/generate.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Generates a throwaway CA plus a server cert (for ingest) and a client +# cert (for the agent) for local mTLS. Dev/homelab only — never use this +# CA or its certs for anything resembling production; there's no rotation, +# no revocation, and the CA key sits unencrypted on disk right next to +# everything it signed. +# +# Re-run to regenerate from scratch; existing output is overwritten. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT_DIR="${SCRIPT_DIR}/out" +DAYS="${DEV_CERT_DAYS:-365}" + +mkdir -p "${OUT_DIR}" +cd "${OUT_DIR}" + +echo "Generating dev CA..." +openssl req -x509 -newkey rsa:4096 -sha256 -days "${DAYS}" -nodes \ + -keyout ca-key.pem -out ca.pem \ + -subj "/O=Sentry Dev/CN=Sentry Dev CA" + +gen_leaf() { + local name="$1" cn="$2" san="$3" + openssl req -newkey rsa:2048 -nodes -keyout "${name}-key.pem" -out "${name}.csr" \ + -subj "/O=Sentry Dev/CN=${cn}" + openssl x509 -req -in "${name}.csr" -CA ca.pem -CAkey ca-key.pem -CAcreateserial \ + -out "${name}.pem" -days "${DAYS}" -sha256 \ + -extfile <(printf "subjectAltName=%s" "${san}") + rm -f "${name}.csr" +} + +# SANs cover both "reached by another container on the compose network" +# (ingest) and "reached from the host" (localhost/127.0.0.1, for an +# agent running natively per /agent/README.md's journald caveat). +echo "Generating server (ingest) cert..." +gen_leaf server ingest "DNS:ingest,DNS:localhost,IP:127.0.0.1" + +echo "Generating client (agent) cert..." +gen_leaf client sentry-agent "DNS:sentry-agent" + +rm -f ca.srl + +echo +echo "Done. Certs written to ${OUT_DIR}/:" +ls "${OUT_DIR}" diff --git a/ingest/Dockerfile b/ingest/Dockerfile new file mode 100644 index 0000000..9612b39 --- /dev/null +++ b/ingest/Dockerfile @@ -0,0 +1,15 @@ +# Build context must be the repo root (sentry/), not ingest/, since this +# needs both ingest/ and proto/: +# docker build -f ingest/Dockerfile -t sentry-ingest . + +FROM golang:1.25-alpine AS builder +WORKDIR /src +COPY proto ./proto +COPY ingest ./ingest +WORKDIR /src/ingest +RUN go mod download +RUN CGO_ENABLED=0 GOOS=linux go build -o /out/ingest ./cmd/ingest + +FROM gcr.io/distroless/static-debian12 +COPY --from=builder /out/ingest /ingest +ENTRYPOINT ["/ingest"] diff --git a/ingest/README.md b/ingest/README.md new file mode 100644 index 0000000..0df09a1 --- /dev/null +++ b/ingest/README.md @@ -0,0 +1,85 @@ +# ingest + +Go service sitting between the Rust agent and ClickHouse. Two halves in one +binary, selected with `--mode`: + +- **server** — mTLS gRPC front end (`LogIngest.PushBatch`) that agents + connect to. Forwards each record, proto-encoded and unchanged, onto + Redpanda. Does no normalization — kept thin so agent-facing latency isn't + coupled to ClickHouse write performance. +- **consumer** — reads back off Redpanda, normalizes into the ClickHouse row + shape (`internal/normalize`), and batch-writes via the native protocol + driver. Commits Redpanda offsets only after a successful ClickHouse + write, so a ClickHouse outage causes redelivery on restart rather than + data loss. +- **all** (default) — both, in one process. This is what docker-compose + runs. Splitting into two deployments later (e.g. to scale them + independently in k8s) is a manifest change, not a code change — see + `--mode`. + +## Why Redpanda stays in the path + +Confirmed with the project owner during Phase 0 planning: the gRPC front +end produces to Redpanda rather than writing ClickHouse directly. This +exercises the pinned transport layer from day one and keeps agents from +ever needing Kafka credentials — mTLS to `ingest` is the only network +egress an agent has. See `/docs/architecture.md`. + +## Dependencies worth knowing about + +- **github.com/segmentio/kafka-go** — pure Go, no cgo, chosen over + franz-go/confluent-kafka-go specifically to keep the distroless build + simple (confirmed with the project owner; see git history / PR + discussion for the tradeoffs considered). +- **github.com/ClickHouse/clickhouse-go/v2** — official client, native + protocol, pure Go (no cgo). +- **golang.org/x/sync/errgroup** — used in `cmd/ingest/main.go` to run the + server and consumer halves concurrently and propagate the first error. + +## Configuration + +All via environment variables (see `internal/config/config.go` for the +full list and defaults) — no config file format for Phase 0: + +| Var | Default | Purpose | +|---|---|---| +| `GRPC_LISTEN_ADDR` | `:4317` | Agent-facing gRPC listen address | +| `TLS_CERT_FILE` / `TLS_KEY_FILE` | `/etc/sentry-ingest/server{,-key}.pem` | ingest's own mTLS identity | +| `TLS_CLIENT_CA_FILE` | `/etc/sentry-ingest/ca.pem` | CA used to verify agent client certs | +| `REDPANDA_BROKERS` | `localhost:9092` | Comma-separated broker list | +| `REDPANDA_TOPIC` | `sentry.logs.raw` | Must match the topic provisioned in `/transport` | +| `REDPANDA_CONSUMER_GROUP` | `sentry-ingest` | Consumer group id | +| `CLICKHOUSE_ADDR` | `localhost:9000` | Native protocol port, not HTTP | +| `CLICKHOUSE_DATABASE` / `_USERNAME` / `_PASSWORD` | `sentry` / `default` / `` | | +| `CONSUMER_BATCH_MAX_SIZE` | `500` | Records per ClickHouse batch insert | +| `CONSUMER_BATCH_FLUSH_INTERVAL_MS` | `2000` | Max time a partial batch waits before flushing | + +## Building & testing + +```sh +go build ./... +go vet ./... +go test ./... +``` + +Requires `google.golang.org/protobuf/cmd/protoc-gen-go` and +`google.golang.org/grpc/cmd/protoc-gen-go-grpc` only if you're +regenerating `/proto`'s Go bindings — ingest itself just imports the +already-generated `github.com/sentry/sentry/proto` module (see the +`replace` directive in `go.mod`, pointing at `../proto`). + +```sh +# from the repo root, not ingest/ +docker build -f ingest/Dockerfile -t sentry-ingest . +``` + +## Testing notes + +`internal/consumer` and `internal/grpcserver` depend on Redpanda and +ClickHouse only through small interfaces (`reader`/`chWriter` in consumer, +`batchProducer` in grpcserver), so the flush/commit/error-handling logic is +unit-tested against fakes — no embedded broker or database needed. What's +*not* covered by these tests: the real `kafka.Reader`/`kafka.Writer` +wiring and the ClickHouse native-protocol driver itself. Those are only +exercised by the docker-compose end-to-end flow described in +`/docs/phase-0-runbook.md`. diff --git a/ingest/cmd/ingest/main.go b/ingest/cmd/ingest/main.go new file mode 100644 index 0000000..f6b504a --- /dev/null +++ b/ingest/cmd/ingest/main.go @@ -0,0 +1,77 @@ +// Command ingest is the Sentry ingest service. It has two halves that can +// run in one process or be split across deployments via --mode: +// +// - server: mTLS gRPC front end that agents push batches to; forwards +// them onto Redpanda unchanged. +// - consumer: reads back off Redpanda, normalizes, batch-writes to +// ClickHouse. +// - all (default): both, in one process — the Phase 0 / docker-compose +// shape. Splitting into separate deployments later is a k8s manifest +// change, not a code change. +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + "golang.org/x/sync/errgroup" + + "github.com/sentry/sentry/ingest/internal/clickhousewriter" + "github.com/sentry/sentry/ingest/internal/config" + "github.com/sentry/sentry/ingest/internal/consumer" + "github.com/sentry/sentry/ingest/internal/grpcserver" + "github.com/sentry/sentry/ingest/internal/producer" +) + +func main() { + mode := flag.String("mode", "all", "which half of ingest to run: server | consumer | all") + flag.Parse() + + if *mode != "server" && *mode != "consumer" && *mode != "all" { + fmt.Fprintf(os.Stderr, "unknown --mode %q, must be server|consumer|all\n", *mode) + os.Exit(1) + } + + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + cfg, err := config.Load() + if err != nil { + logger.Error("loading config", "error", err) + os.Exit(1) + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + g, ctx := errgroup.WithContext(ctx) + + if *mode == "server" || *mode == "all" { + p := producer.New(cfg.Redpanda) + defer p.Close() + srv := grpcserver.New(logger, cfg.GRPC, cfg.TLS, p) + g.Go(func() error { return srv.Run(ctx) }) + } + + if *mode == "consumer" || *mode == "all" { + chw, err := clickhousewriter.New(ctx, cfg.ClickHouse) + if err != nil { + logger.Error("connecting to clickhouse", "error", err) + os.Exit(1) + } + defer chw.Close() + c := consumer.New(logger, cfg.Redpanda, cfg.Batch, chw) + g.Go(func() error { return c.Run(ctx) }) + } + + logger.Info("ingest started", "mode", *mode) + + if err := g.Wait(); err != nil { + logger.Error("ingest exited with error", "error", err) + os.Exit(1) + } +} diff --git a/ingest/go.mod b/ingest/go.mod new file mode 100644 index 0000000..a5fbcb5 --- /dev/null +++ b/ingest/go.mod @@ -0,0 +1,34 @@ +module github.com/sentry/sentry/ingest + +go 1.25.0 + +replace github.com/sentry/sentry/proto => ../proto + +require ( + github.com/ClickHouse/clickhouse-go/v2 v2.48.0 + github.com/segmentio/kafka-go v0.4.51 + github.com/sentry/sentry/proto v0.0.0-00010101000000-000000000000 + golang.org/x/sync v0.22.0 + google.golang.org/grpc v1.83.0 + google.golang.org/protobuf v1.36.12 +) + +require ( + github.com/ClickHouse/ch-go v0.74.0 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.19.1 // indirect + github.com/paulmach/orb v0.13.0 // indirect + github.com/pierrec/lz4/v4 v4.1.27 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect +) diff --git a/ingest/go.sum b/ingest/go.sum new file mode 100644 index 0000000..3d7b4e9 --- /dev/null +++ b/ingest/go.sum @@ -0,0 +1,78 @@ +github.com/ClickHouse/ch-go v0.74.0 h1:uYs2m4wIt0ZHSM1E72rg0maCfzhR2V3xWb/vZEgpeWE= +github.com/ClickHouse/ch-go v0.74.0/go.mod h1:sZ/r+8ttZMjyrP9PuFbgoVbth1ywIu2LIQNA2vgko6M= +github.com/ClickHouse/clickhouse-go/v2 v2.48.0 h1:auzd4VkapQYhQF8F2Gog7s3x78Bi1JZmByxGbrw3C+4= +github.com/ClickHouse/clickhouse-go/v2 v2.48.0/go.mod h1:lBjUCPRG6RpRQdMbkXq+JV8rY0/O5lw+Z7jShgReFjM= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw= +github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k= +github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= +github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno= +github.com/segmentio/kafka-go v0.4.51/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ingest/internal/clickhousewriter/writer.go b/ingest/internal/clickhousewriter/writer.go new file mode 100644 index 0000000..37a3d85 --- /dev/null +++ b/ingest/internal/clickhousewriter/writer.go @@ -0,0 +1,60 @@ +// Package clickhousewriter batch-inserts normalized log rows into +// ClickHouse using the native protocol driver's batch API. +package clickhousewriter + +import ( + "context" + "fmt" + + "github.com/ClickHouse/clickhouse-go/v2" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + + "github.com/sentry/sentry/ingest/internal/config" + "github.com/sentry/sentry/ingest/internal/normalize" + logsv1 "github.com/sentry/sentry/proto/sentry/logs/v1" +) + +type Writer struct { + conn driver.Conn +} + +func New(ctx context.Context, cfg config.ClickHouseConfig) (*Writer, error) { + conn, err := clickhouse.Open(&clickhouse.Options{ + Addr: []string{cfg.Addr}, + Auth: clickhouse.Auth{ + Database: cfg.Database, + Username: cfg.Username, + Password: cfg.Password, + }, + }) + if err != nil { + return nil, fmt.Errorf("opening clickhouse connection: %w", err) + } + if err := conn.Ping(ctx); err != nil { + return nil, fmt.Errorf("pinging clickhouse: %w", err) + } + return &Writer{conn: conn}, nil +} + +func (w *Writer) Close() error { + return w.conn.Close() +} + +func (w *Writer) WriteBatch(ctx context.Context, records []*logsv1.LogRecord) error { + batch, err := w.conn.PrepareBatch(ctx, "INSERT INTO logs (timestamp, host, service, severity, message, attributes)") + if err != nil { + return fmt.Errorf("preparing batch: %w", err) + } + + for _, rec := range records { + row := normalize.ToRow(rec) + if err := batch.Append(row.Timestamp, row.Host, row.Service, row.Severity, row.Message, row.Attributes); err != nil { + return fmt.Errorf("appending row to batch: %w", err) + } + } + + if err := batch.Send(); err != nil { + return fmt.Errorf("sending batch: %w", err) + } + return nil +} diff --git a/ingest/internal/config/config.go b/ingest/internal/config/config.go new file mode 100644 index 0000000..40955a8 --- /dev/null +++ b/ingest/internal/config/config.go @@ -0,0 +1,95 @@ +// Package config loads ingest's configuration from environment variables. +// Phase 0 deliberately has no config file format of its own — env vars are +// enough for a docker-compose/k8s deployment and avoid pulling in a config +// library. +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +type Config struct { + GRPC GRPCConfig + TLS TLSConfig + Redpanda RedpandaConfig + ClickHouse ClickHouseConfig + Batch BatchConfig +} + +type GRPCConfig struct { + ListenAddr string +} + +// TLSConfig is the server-side mTLS material: the ingest service's own +// cert/key, and the CA used to verify agent client certs. +type TLSConfig struct { + CertFile string + KeyFile string + ClientCAFile string +} + +type RedpandaConfig struct { + Brokers []string + Topic string + ConsumerGroup string +} + +type ClickHouseConfig struct { + Addr string + Database string + Username string + Password string +} + +type BatchConfig struct { + MaxSize int + FlushIntervalMS int +} + +func Load() (Config, error) { + cfg := Config{ + GRPC: GRPCConfig{ + ListenAddr: getenv("GRPC_LISTEN_ADDR", ":4317"), + }, + TLS: TLSConfig{ + CertFile: getenv("TLS_CERT_FILE", "/etc/sentry-ingest/server.pem"), + KeyFile: getenv("TLS_KEY_FILE", "/etc/sentry-ingest/server-key.pem"), + ClientCAFile: getenv("TLS_CLIENT_CA_FILE", "/etc/sentry-ingest/ca.pem"), + }, + Redpanda: RedpandaConfig{ + Brokers: strings.Split(getenv("REDPANDA_BROKERS", "localhost:9092"), ","), + Topic: getenv("REDPANDA_TOPIC", "sentry.logs.raw"), + ConsumerGroup: getenv("REDPANDA_CONSUMER_GROUP", "sentry-ingest"), + }, + ClickHouse: ClickHouseConfig{ + Addr: getenv("CLICKHOUSE_ADDR", "localhost:9000"), + Database: getenv("CLICKHOUSE_DATABASE", "sentry"), + Username: getenv("CLICKHOUSE_USERNAME", "default"), + Password: getenv("CLICKHOUSE_PASSWORD", ""), + }, + } + + maxSize, err := strconv.Atoi(getenv("CONSUMER_BATCH_MAX_SIZE", "500")) + if err != nil { + return Config{}, fmt.Errorf("CONSUMER_BATCH_MAX_SIZE: %w", err) + } + cfg.Batch.MaxSize = maxSize + + flushMS, err := strconv.Atoi(getenv("CONSUMER_BATCH_FLUSH_INTERVAL_MS", "2000")) + if err != nil { + return Config{}, fmt.Errorf("CONSUMER_BATCH_FLUSH_INTERVAL_MS: %w", err) + } + cfg.Batch.FlushIntervalMS = flushMS + + return cfg, nil +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/ingest/internal/config/config_test.go b/ingest/internal/config/config_test.go new file mode 100644 index 0000000..6ec7f8a --- /dev/null +++ b/ingest/internal/config/config_test.go @@ -0,0 +1,49 @@ +package config + +import "testing" + +func TestLoadDefaults(t *testing.T) { + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.GRPC.ListenAddr != ":4317" { + t.Errorf("GRPC.ListenAddr = %q, want :4317", cfg.GRPC.ListenAddr) + } + if cfg.Redpanda.Topic != "sentry.logs.raw" { + t.Errorf("Redpanda.Topic = %q, want sentry.logs.raw", cfg.Redpanda.Topic) + } + if cfg.Batch.MaxSize != 500 { + t.Errorf("Batch.MaxSize = %d, want 500", cfg.Batch.MaxSize) + } + if cfg.Batch.FlushIntervalMS != 2000 { + t.Errorf("Batch.FlushIntervalMS = %d, want 2000", cfg.Batch.FlushIntervalMS) + } +} + +func TestLoadOverridesFromEnv(t *testing.T) { + t.Setenv("GRPC_LISTEN_ADDR", ":9999") + t.Setenv("REDPANDA_BROKERS", "a:9092,b:9092") + t.Setenv("CONSUMER_BATCH_MAX_SIZE", "10") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.GRPC.ListenAddr != ":9999" { + t.Errorf("GRPC.ListenAddr = %q, want :9999", cfg.GRPC.ListenAddr) + } + if len(cfg.Redpanda.Brokers) != 2 || cfg.Redpanda.Brokers[0] != "a:9092" || cfg.Redpanda.Brokers[1] != "b:9092" { + t.Errorf("Redpanda.Brokers = %+v, want [a:9092 b:9092]", cfg.Redpanda.Brokers) + } + if cfg.Batch.MaxSize != 10 { + t.Errorf("Batch.MaxSize = %d, want 10", cfg.Batch.MaxSize) + } +} + +func TestLoadInvalidBatchSizeErrors(t *testing.T) { + t.Setenv("CONSUMER_BATCH_MAX_SIZE", "not-a-number") + if _, err := Load(); err == nil { + t.Fatal("expected error for non-numeric CONSUMER_BATCH_MAX_SIZE, got nil") + } +} diff --git a/ingest/internal/consumer/consumer.go b/ingest/internal/consumer/consumer.go new file mode 100644 index 0000000..7692d3d --- /dev/null +++ b/ingest/internal/consumer/consumer.go @@ -0,0 +1,124 @@ +// Package consumer reads normalized-on-write LogRecords back off Redpanda +// and batch-writes them into ClickHouse. Offsets are committed only after +// a successful ClickHouse write, so a ClickHouse outage causes redelivery +// on restart rather than silent data loss (at-least-once, not exactly-once +// — Phase 0 doesn't dedupe on the consumer side). +package consumer + +import ( + "context" + "log/slog" + "time" + + "github.com/segmentio/kafka-go" + "google.golang.org/protobuf/proto" + + "github.com/sentry/sentry/ingest/internal/config" + logsv1 "github.com/sentry/sentry/proto/sentry/logs/v1" +) + +// chWriter is the subset of *clickhousewriter.Writer this package depends +// on, kept as an interface so the flush loop is unit-testable without a +// real ClickHouse connection. +type chWriter interface { + WriteBatch(ctx context.Context, records []*logsv1.LogRecord) error +} + +// reader is the subset of *kafka.Reader used here, as an interface so the +// flush/commit logic can be tested against a fake without a real broker. +type reader interface { + FetchMessage(ctx context.Context) (kafka.Message, error) + CommitMessages(ctx context.Context, msgs ...kafka.Message) error + Close() error +} + +type Consumer struct { + logger *slog.Logger + reader reader + writer chWriter + batchCfg config.BatchConfig +} + +func New(logger *slog.Logger, redpandaCfg config.RedpandaConfig, batchCfg config.BatchConfig, w chWriter) *Consumer { + r := kafka.NewReader(kafka.ReaderConfig{ + Brokers: redpandaCfg.Brokers, + Topic: redpandaCfg.Topic, + GroupID: redpandaCfg.ConsumerGroup, + }) + return &Consumer{logger: logger, reader: r, writer: w, batchCfg: batchCfg} +} + +func (c *Consumer) Run(ctx context.Context) error { + defer c.reader.Close() + + flushInterval := time.Duration(c.batchCfg.FlushIntervalMS) * time.Millisecond + ticker := time.NewTicker(flushInterval) + defer ticker.Stop() + + msgCh := make(chan kafka.Message) + fetchErrCh := make(chan error, 1) + + go func() { + for { + m, err := c.reader.FetchMessage(ctx) + if err != nil { + fetchErrCh <- err + return + } + select { + case msgCh <- m: + case <-ctx.Done(): + return + } + } + }() + + var records []*logsv1.LogRecord + var pending []kafka.Message + + flush := func() { + if len(records) == 0 { + return + } + if err := c.writer.WriteBatch(ctx, records); err != nil { + c.logger.Error("clickhouse batch write failed, offsets not committed, will redeliver", + "records", len(records), "error", err) + } else if err := c.reader.CommitMessages(ctx, pending...); err != nil { + c.logger.Error("committing offsets after clickhouse write", "error", err) + } else { + c.logger.Debug("batch flushed to clickhouse", "records", len(records)) + } + records = records[:0] + pending = pending[:0] + } + + for { + select { + case <-ctx.Done(): + flush() + return nil + case err := <-fetchErrCh: + flush() + if ctx.Err() != nil { + return nil + } + return err + case <-ticker.C: + flush() + case m := <-msgCh: + var rec logsv1.LogRecord + if err := proto.Unmarshal(m.Value, &rec); err != nil { + c.logger.Warn("skipping unparseable message", "error", err, "offset", m.Offset) + if cerr := c.reader.CommitMessages(ctx, m); cerr != nil { + c.logger.Error("committing offset for poison message", "error", cerr) + } + continue + } + records = append(records, &rec) + pending = append(pending, m) + if len(records) >= c.batchCfg.MaxSize { + flush() + } + } + } +} diff --git a/ingest/internal/consumer/consumer_test.go b/ingest/internal/consumer/consumer_test.go new file mode 100644 index 0000000..8624d83 --- /dev/null +++ b/ingest/internal/consumer/consumer_test.go @@ -0,0 +1,178 @@ +package consumer + +import ( + "context" + "errors" + "io" + "log/slog" + "sync" + "testing" + "time" + + "github.com/segmentio/kafka-go" + "google.golang.org/protobuf/proto" + + "github.com/sentry/sentry/ingest/internal/config" + logsv1 "github.com/sentry/sentry/proto/sentry/logs/v1" +) + +type fakeReader struct { + msgs chan kafka.Message + + mu sync.Mutex + committed [][]kafka.Message +} + +func newFakeReader() *fakeReader { + return &fakeReader{msgs: make(chan kafka.Message, 16)} +} + +func (f *fakeReader) push(m kafka.Message) { f.msgs <- m } + +func (f *fakeReader) FetchMessage(ctx context.Context) (kafka.Message, error) { + select { + case m := <-f.msgs: + return m, nil + case <-ctx.Done(): + return kafka.Message{}, ctx.Err() + } +} + +func (f *fakeReader) CommitMessages(_ context.Context, msgs ...kafka.Message) error { + f.mu.Lock() + defer f.mu.Unlock() + f.committed = append(f.committed, msgs) + return nil +} + +func (f *fakeReader) Close() error { return nil } + +func (f *fakeReader) commitCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.committed) +} + +type fakeWriter struct { + mu sync.Mutex + batches [][]*logsv1.LogRecord + failNext bool +} + +func (f *fakeWriter) WriteBatch(_ context.Context, records []*logsv1.LogRecord) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.failNext { + f.failNext = false + return errors.New("simulated clickhouse failure") + } + batch := make([]*logsv1.LogRecord, len(records)) + copy(batch, records) + f.batches = append(f.batches, batch) + return nil +} + +func (f *fakeWriter) batchCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.batches) +} + +func newTestConsumer(r reader, w chWriter, batchCfg config.BatchConfig) *Consumer { + return &Consumer{ + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + reader: r, + writer: w, + batchCfg: batchCfg, + } +} + +func mustMarshal(t *testing.T, rec *logsv1.LogRecord) []byte { + t.Helper() + b, err := proto.Marshal(rec) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +func waitFor(t *testing.T, timeout time.Duration, cond func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("condition not met before timeout") +} + +func TestConsumerFlushesOnBatchSize(t *testing.T) { + fr := newFakeReader() + fw := &fakeWriter{} + c := newTestConsumer(fr, fw, config.BatchConfig{MaxSize: 2, FlushIntervalMS: 60_000}) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- c.Run(ctx) }() + + fr.push(kafka.Message{Value: mustMarshal(t, &logsv1.LogRecord{Message: "a"})}) + fr.push(kafka.Message{Value: mustMarshal(t, &logsv1.LogRecord{Message: "b"})}) + + waitFor(t, time.Second, func() bool { return fw.batchCount() == 1 }) + + fw.mu.Lock() + if len(fw.batches[0]) != 2 { + t.Fatalf("expected batch of 2 records, got %d", len(fw.batches[0])) + } + fw.mu.Unlock() + + waitFor(t, time.Second, func() bool { return fr.commitCount() == 1 }) +} + +func TestConsumerFlushesOnTimeout(t *testing.T) { + fr := newFakeReader() + fw := &fakeWriter{} + c := newTestConsumer(fr, fw, config.BatchConfig{MaxSize: 1000, FlushIntervalMS: 20}) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- c.Run(ctx) }() + + fr.push(kafka.Message{Value: mustMarshal(t, &logsv1.LogRecord{Message: "only-one"})}) + + waitFor(t, time.Second, func() bool { return fw.batchCount() == 1 }) + + fw.mu.Lock() + if len(fw.batches[0]) != 1 { + t.Fatalf("expected batch of 1 record, got %d", len(fw.batches[0])) + } + fw.mu.Unlock() +} + +func TestConsumerDoesNotCommitOnWriteFailure(t *testing.T) { + fr := newFakeReader() + fw := &fakeWriter{failNext: true} + c := newTestConsumer(fr, fw, config.BatchConfig{MaxSize: 1, FlushIntervalMS: 60_000}) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- c.Run(ctx) }() + + fr.push(kafka.Message{Value: mustMarshal(t, &logsv1.LogRecord{Message: "will-fail"})}) + + // Give the flush a moment to run and fail. + time.Sleep(100 * time.Millisecond) + + if got := fr.commitCount(); got != 0 { + t.Fatalf("expected no commits after a failed clickhouse write, got %d", got) + } + // The batch was attempted even though writer returned an error. + if fw.batchCount() != 0 { + t.Fatalf("fakeWriter should not record a failed batch, got %d recorded", fw.batchCount()) + } +} diff --git a/ingest/internal/grpcserver/server.go b/ingest/internal/grpcserver/server.go new file mode 100644 index 0000000..cd23fae --- /dev/null +++ b/ingest/internal/grpcserver/server.go @@ -0,0 +1,96 @@ +// Package grpcserver implements the agent-facing side of ingest: an mTLS +// gRPC server accepting LogIngest.PushBatch calls, which it forwards +// unchanged (proto-encoded) onto Redpanda. Normalization into the +// ClickHouse row shape happens later, on the consumer side. +package grpcserver + +import ( + "context" + "fmt" + "log/slog" + "net" + + "github.com/segmentio/kafka-go" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + + "github.com/sentry/sentry/ingest/internal/config" + logsv1 "github.com/sentry/sentry/proto/sentry/logs/v1" +) + +type Server struct { + logsv1.UnimplementedLogIngestServer + + logger *slog.Logger + grpcCfg config.GRPCConfig + tlsCfg config.TLSConfig + producer batchProducer +} + +// batchProducer is the subset of *producer.Producer this package depends +// on, so tests can substitute a fake without touching Redpanda. +type batchProducer interface { + WriteBatch(ctx context.Context, msgs []kafka.Message) error +} + +func New(logger *slog.Logger, grpcCfg config.GRPCConfig, tlsCfg config.TLSConfig, p batchProducer) *Server { + return &Server{logger: logger, grpcCfg: grpcCfg, tlsCfg: tlsCfg, producer: p} +} + +// Run blocks serving gRPC until ctx is canceled, then gracefully stops. +func (s *Server) Run(ctx context.Context) error { + tlsConf, err := loadServerTLSConfig(s.tlsCfg) + if err != nil { + return fmt.Errorf("loading TLS config: %w", err) + } + + lis, err := net.Listen("tcp", s.grpcCfg.ListenAddr) + if err != nil { + return fmt.Errorf("listening on %s: %w", s.grpcCfg.ListenAddr, err) + } + + grpcSrv := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConf))) + logsv1.RegisterLogIngestServer(grpcSrv, s) + + s.logger.Info("gRPC server listening", "addr", s.grpcCfg.ListenAddr) + + errCh := make(chan error, 1) + go func() { errCh <- grpcSrv.Serve(lis) }() + + select { + case <-ctx.Done(): + grpcSrv.GracefulStop() + return nil + case err := <-errCh: + return err + } +} + +func (s *Server) PushBatch(ctx context.Context, req *logsv1.PushBatchRequest) (*logsv1.PushBatchResponse, error) { + if len(req.GetRecords()) == 0 { + return &logsv1.PushBatchResponse{Accepted: 0}, nil + } + + msgs := make([]kafka.Message, 0, len(req.GetRecords())) + for _, rec := range req.GetRecords() { + val, err := proto.Marshal(rec) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "marshaling record: %v", err) + } + msgs = append(msgs, kafka.Message{ + Key: []byte(rec.GetHost()), + Value: val, + }) + } + + if err := s.producer.WriteBatch(ctx, msgs); err != nil { + s.logger.Error("failed to write batch to redpanda", "batch_id", req.GetBatchId(), "error", err) + return nil, status.Errorf(codes.Unavailable, "writing to transport: %v", err) + } + + s.logger.Debug("batch produced to redpanda", "batch_id", req.GetBatchId(), "records", len(req.GetRecords())) + return &logsv1.PushBatchResponse{Accepted: uint32(len(req.GetRecords()))}, nil +} diff --git a/ingest/internal/grpcserver/tls.go b/ingest/internal/grpcserver/tls.go new file mode 100644 index 0000000..093ecea --- /dev/null +++ b/ingest/internal/grpcserver/tls.go @@ -0,0 +1,36 @@ +package grpcserver + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + + "github.com/sentry/sentry/ingest/internal/config" +) + +// loadServerTLSConfig builds the mTLS server config: ingest's own +// certificate, plus the CA used to verify agent client certificates. +// Agents are never accepted without a client cert signed by this CA. +func loadServerTLSConfig(cfg config.TLSConfig) (*tls.Config, error) { + cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) + if err != nil { + return nil, fmt.Errorf("loading server cert/key: %w", err) + } + + caPEM, err := os.ReadFile(cfg.ClientCAFile) + if err != nil { + return nil, fmt.Errorf("reading client CA file: %w", err) + } + caPool := x509.NewCertPool() + if !caPool.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("no valid certificates found in client CA file %s", cfg.ClientCAFile) + } + + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: caPool, + MinVersion: tls.VersionTLS12, + }, nil +} diff --git a/ingest/internal/normalize/normalize.go b/ingest/internal/normalize/normalize.go new file mode 100644 index 0000000..5c3b6e4 --- /dev/null +++ b/ingest/internal/normalize/normalize.go @@ -0,0 +1,58 @@ +// Package normalize maps the wire-format LogRecord (as agents send it) +// into the ClickHouse row shape defined in /storage. This is the "OTel-log- +// like schema" normalization step called for in the ingest design — Phase +// 0 keeps it to the minimal column set; full OTel field mapping (separate +// SeverityNumber/SeverityText, resource attributes, etc.) is deferred, see +// the open questions in /docs/architecture.md. +package normalize + +import ( + "time" + + logsv1 "github.com/sentry/sentry/proto/sentry/logs/v1" +) + +type Row struct { + Timestamp time.Time + Host string + Service string + Severity string + Message string + Attributes map[string]string +} + +func ToRow(rec *logsv1.LogRecord) Row { + attrs := rec.GetAttributes() + if attrs == nil { + attrs = map[string]string{} + } + return Row{ + Timestamp: time.Unix(0, rec.GetTimestampUnixNano()).UTC(), + Host: rec.GetHost(), + Service: rec.GetService(), + Severity: severityText(rec.GetSeverity()), + Message: rec.GetMessage(), + Attributes: attrs, + } +} + +// severityText maps the proto Severity enum to short OTel-style severity +// names, stored as the `severity` column's value. +func severityText(sev logsv1.Severity) string { + switch sev { + case logsv1.Severity_SEVERITY_TRACE: + return "TRACE" + case logsv1.Severity_SEVERITY_DEBUG: + return "DEBUG" + case logsv1.Severity_SEVERITY_INFO: + return "INFO" + case logsv1.Severity_SEVERITY_WARN: + return "WARN" + case logsv1.Severity_SEVERITY_ERROR: + return "ERROR" + case logsv1.Severity_SEVERITY_FATAL: + return "FATAL" + default: + return "UNSPECIFIED" + } +} diff --git a/ingest/internal/normalize/normalize_test.go b/ingest/internal/normalize/normalize_test.go new file mode 100644 index 0000000..b8816cd --- /dev/null +++ b/ingest/internal/normalize/normalize_test.go @@ -0,0 +1,68 @@ +package normalize + +import ( + "testing" + "time" + + logsv1 "github.com/sentry/sentry/proto/sentry/logs/v1" +) + +func TestToRowMapsFieldsAndSeverity(t *testing.T) { + rec := &logsv1.LogRecord{ + TimestampUnixNano: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC).UnixNano(), + Host: "host-1", + Service: "svc-a", + Severity: logsv1.Severity_SEVERITY_ERROR, + Message: "boom", + Attributes: map[string]string{"k": "v"}, + } + + row := ToRow(rec) + + if row.Host != "host-1" || row.Service != "svc-a" || row.Message != "boom" { + t.Fatalf("unexpected row: %+v", row) + } + if row.Severity != "ERROR" { + t.Fatalf("expected severity ERROR, got %s", row.Severity) + } + if row.Attributes["k"] != "v" { + t.Fatalf("expected attribute k=v, got %+v", row.Attributes) + } + if !row.Timestamp.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) { + t.Fatalf("unexpected timestamp: %v", row.Timestamp) + } +} + +func TestToRowNilAttributesBecomesEmptyMap(t *testing.T) { + rec := &logsv1.LogRecord{Host: "h", Service: "s", Message: "m"} + row := ToRow(rec) + if row.Attributes == nil { + t.Fatal("expected non-nil empty map, got nil") + } + if len(row.Attributes) != 0 { + t.Fatalf("expected empty map, got %+v", row.Attributes) + } +} + +func TestSeverityTextCoversAllEnumValues(t *testing.T) { + cases := map[logsv1.Severity]string{ + logsv1.Severity_SEVERITY_UNSPECIFIED: "UNSPECIFIED", + logsv1.Severity_SEVERITY_TRACE: "TRACE", + logsv1.Severity_SEVERITY_DEBUG: "DEBUG", + logsv1.Severity_SEVERITY_INFO: "INFO", + logsv1.Severity_SEVERITY_WARN: "WARN", + logsv1.Severity_SEVERITY_ERROR: "ERROR", + logsv1.Severity_SEVERITY_FATAL: "FATAL", + } + for sev, want := range cases { + if got := severityText(sev); got != want { + t.Errorf("severityText(%v) = %q, want %q", sev, got, want) + } + } +} + +func TestSeverityTextUnknownValueFallsBackToUnspecified(t *testing.T) { + if got := severityText(logsv1.Severity(99)); got != "UNSPECIFIED" { + t.Fatalf("expected UNSPECIFIED for unknown severity, got %q", got) + } +} diff --git a/ingest/internal/producer/producer.go b/ingest/internal/producer/producer.go new file mode 100644 index 0000000..375e4b6 --- /dev/null +++ b/ingest/internal/producer/producer.go @@ -0,0 +1,42 @@ +// Package producer wraps the Redpanda (Kafka API) producer used by the +// gRPC front end to forward agent-submitted batches onto the transport +// layer, unchanged. OTel-log-shape normalization happens later, on the +// consumer side — see internal/normalize. +package producer + +import ( + "context" + + "github.com/segmentio/kafka-go" + + "github.com/sentry/sentry/ingest/internal/config" +) + +type Producer struct { + writer *kafka.Writer +} + +func New(cfg config.RedpandaConfig) *Producer { + return &Producer{ + writer: &kafka.Writer{ + Addr: kafka.TCP(cfg.Brokers...), + Topic: cfg.Topic, + // Partition by host so a single host's records stay in + // relative order within a partition. + Balancer: &kafka.Hash{}, + RequiredAcks: kafka.RequireOne, + AllowAutoTopicCreation: false, // topics are provisioned explicitly, see /transport + }, + } +} + +func (p *Producer) Close() error { + return p.writer.Close() +} + +// WriteBatch writes all messages in one call. kafka-go's WriteMessages +// either succeeds for the whole batch or returns an error, which matches +// the PushBatch RPC's all-or-nothing contract for Phase 0. +func (p *Producer) WriteBatch(ctx context.Context, msgs []kafka.Message) error { + return p.writer.WriteMessages(ctx, msgs...) +} diff --git a/proto/README.md b/proto/README.md new file mode 100644 index 0000000..9b86de6 --- /dev/null +++ b/proto/README.md @@ -0,0 +1,30 @@ +# proto + +Shared `.proto` contracts. Source of truth for the agent↔ingest gRPC +service; each language generates its own bindings from these files rather +than sharing generated code across languages. + +- `sentry/logs/v1/logs.proto` — `LogIngest.PushBatch`, the only RPC an + agent ever calls. + +## Go bindings + +Go is the one language here with pre-generated, checked-in bindings +(`sentry/logs/v1/logs.pb.go`, `logs_grpc.pb.go`), living in this directory +as its own module (`github.com/sentry/sentry/proto`) that `/ingest` and +`/api` depend on via a local `replace` directive in their `go.mod`. Rust +(`/agent`) instead generates its bindings at build time via `tonic-build` +(see `agent/sentry-agent/build.rs`) — no checked-in Rust output. + +To regenerate the Go bindings after changing `logs.proto`: + +```sh +go install google.golang.org/protobuf/cmd/protoc-gen-go@latest +go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + +cd proto +protoc --go_out=. --go_opt=paths=source_relative \ + --go-grpc_out=. --go-grpc_opt=paths=source_relative \ + sentry/logs/v1/logs.proto +go build ./... +``` diff --git a/proto/go.mod b/proto/go.mod new file mode 100644 index 0000000..535ced8 --- /dev/null +++ b/proto/go.mod @@ -0,0 +1,15 @@ +module github.com/sentry/sentry/proto + +go 1.25.0 + +require ( + google.golang.org/grpc v1.83.0 + google.golang.org/protobuf v1.36.12 +) + +require ( + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect +) diff --git a/proto/go.sum b/proto/go.sum new file mode 100644 index 0000000..fcc6745 --- /dev/null +++ b/proto/go.sum @@ -0,0 +1,38 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/proto/sentry/logs/v1/logs.pb.go b/proto/sentry/logs/v1/logs.pb.go new file mode 100644 index 0000000..30cd2bc --- /dev/null +++ b/proto/sentry/logs/v1/logs.pb.go @@ -0,0 +1,376 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc v7.35.1 +// source: sentry/logs/v1/logs.proto + +package logsv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Severity follows OTel's severity number ranges (1-24), collapsed here to +// the coarse names agents actually need to set. Numeric value stored +// downstream may be a full OTel severity_number computed by ingest. +type Severity int32 + +const ( + Severity_SEVERITY_UNSPECIFIED Severity = 0 + Severity_SEVERITY_TRACE Severity = 1 + Severity_SEVERITY_DEBUG Severity = 2 + Severity_SEVERITY_INFO Severity = 3 + Severity_SEVERITY_WARN Severity = 4 + Severity_SEVERITY_ERROR Severity = 5 + Severity_SEVERITY_FATAL Severity = 6 +) + +// Enum value maps for Severity. +var ( + Severity_name = map[int32]string{ + 0: "SEVERITY_UNSPECIFIED", + 1: "SEVERITY_TRACE", + 2: "SEVERITY_DEBUG", + 3: "SEVERITY_INFO", + 4: "SEVERITY_WARN", + 5: "SEVERITY_ERROR", + 6: "SEVERITY_FATAL", + } + Severity_value = map[string]int32{ + "SEVERITY_UNSPECIFIED": 0, + "SEVERITY_TRACE": 1, + "SEVERITY_DEBUG": 2, + "SEVERITY_INFO": 3, + "SEVERITY_WARN": 4, + "SEVERITY_ERROR": 5, + "SEVERITY_FATAL": 6, + } +) + +func (x Severity) Enum() *Severity { + p := new(Severity) + *p = x + return p +} + +func (x Severity) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Severity) Descriptor() protoreflect.EnumDescriptor { + return file_sentry_logs_v1_logs_proto_enumTypes[0].Descriptor() +} + +func (Severity) Type() protoreflect.EnumType { + return &file_sentry_logs_v1_logs_proto_enumTypes[0] +} + +func (x Severity) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Severity.Descriptor instead. +func (Severity) EnumDescriptor() ([]byte, []int) { + return file_sentry_logs_v1_logs_proto_rawDescGZIP(), []int{0} +} + +type LogRecord struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Unix epoch nanoseconds, set by the agent at time of read (not parse or + // send time) to preserve original ordering as closely as possible. + TimestampUnixNano int64 `protobuf:"varint,1,opt,name=timestamp_unix_nano,json=timestampUnixNano,proto3" json:"timestamp_unix_nano,omitempty"` + // Hostname the agent is running on. Agent fills this from its own config + // or system hostname; not trusted as an identity claim (mTLS client cert + // is the identity boundary). + Host string `protobuf:"bytes,2,opt,name=host,proto3" json:"host,omitempty"` + // Logical service/unit name. For journald sources, this is typically the + // systemd unit name; for file sources, it comes from agent config. + Service string `protobuf:"bytes,3,opt,name=service,proto3" json:"service,omitempty"` + Severity Severity `protobuf:"varint,4,opt,name=severity,proto3,enum=sentry.logs.v1.Severity" json:"severity,omitempty"` + // Original, unparsed log line. Always populated, even when structured + // fields below are also present, per the schema-on-read fallback + // requirement in CLAUDE.md. + Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` + // Structured fields extracted by the agent's parser (e.g. RFC 5424 + // syslog header fields). Empty when the raw-passthrough fallback fires. + Attributes map[string]string `protobuf:"bytes,6,rep,name=attributes,proto3" json:"attributes,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LogRecord) Reset() { + *x = LogRecord{} + mi := &file_sentry_logs_v1_logs_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LogRecord) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LogRecord) ProtoMessage() {} + +func (x *LogRecord) ProtoReflect() protoreflect.Message { + mi := &file_sentry_logs_v1_logs_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LogRecord.ProtoReflect.Descriptor instead. +func (*LogRecord) Descriptor() ([]byte, []int) { + return file_sentry_logs_v1_logs_proto_rawDescGZIP(), []int{0} +} + +func (x *LogRecord) GetTimestampUnixNano() int64 { + if x != nil { + return x.TimestampUnixNano + } + return 0 +} + +func (x *LogRecord) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *LogRecord) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *LogRecord) GetSeverity() Severity { + if x != nil { + return x.Severity + } + return Severity_SEVERITY_UNSPECIFIED +} + +func (x *LogRecord) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *LogRecord) GetAttributes() map[string]string { + if x != nil { + return x.Attributes + } + return nil +} + +type PushBatchRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Agent-assigned identifier for dedup/idempotency on retry. Ingest may + // use this to avoid double-writing a batch if a retry follows a + // timeout on an actually-successful push. + BatchId string `protobuf:"bytes,1,opt,name=batch_id,json=batchId,proto3" json:"batch_id,omitempty"` + Records []*LogRecord `protobuf:"bytes,2,rep,name=records,proto3" json:"records,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PushBatchRequest) Reset() { + *x = PushBatchRequest{} + mi := &file_sentry_logs_v1_logs_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PushBatchRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PushBatchRequest) ProtoMessage() {} + +func (x *PushBatchRequest) ProtoReflect() protoreflect.Message { + mi := &file_sentry_logs_v1_logs_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PushBatchRequest.ProtoReflect.Descriptor instead. +func (*PushBatchRequest) Descriptor() ([]byte, []int) { + return file_sentry_logs_v1_logs_proto_rawDescGZIP(), []int{1} +} + +func (x *PushBatchRequest) GetBatchId() string { + if x != nil { + return x.BatchId + } + return "" +} + +func (x *PushBatchRequest) GetRecords() []*LogRecord { + if x != nil { + return x.Records + } + return nil +} + +type PushBatchResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Number of records ingest accepted. Phase 0: batches are all-or-nothing, + // so this equals len(records) on success. Partial-acceptance semantics + // are not implemented yet. + Accepted uint32 `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PushBatchResponse) Reset() { + *x = PushBatchResponse{} + mi := &file_sentry_logs_v1_logs_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PushBatchResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PushBatchResponse) ProtoMessage() {} + +func (x *PushBatchResponse) ProtoReflect() protoreflect.Message { + mi := &file_sentry_logs_v1_logs_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PushBatchResponse.ProtoReflect.Descriptor instead. +func (*PushBatchResponse) Descriptor() ([]byte, []int) { + return file_sentry_logs_v1_logs_proto_rawDescGZIP(), []int{2} +} + +func (x *PushBatchResponse) GetAccepted() uint32 { + if x != nil { + return x.Accepted + } + return 0 +} + +var File_sentry_logs_v1_logs_proto protoreflect.FileDescriptor + +const file_sentry_logs_v1_logs_proto_rawDesc = "" + + "\n" + + "\x19sentry/logs/v1/logs.proto\x12\x0esentry.logs.v1\"\xc3\x02\n" + + "\tLogRecord\x12.\n" + + "\x13timestamp_unix_nano\x18\x01 \x01(\x03R\x11timestampUnixNano\x12\x12\n" + + "\x04host\x18\x02 \x01(\tR\x04host\x12\x18\n" + + "\aservice\x18\x03 \x01(\tR\aservice\x124\n" + + "\bseverity\x18\x04 \x01(\x0e2\x18.sentry.logs.v1.SeverityR\bseverity\x12\x18\n" + + "\amessage\x18\x05 \x01(\tR\amessage\x12I\n" + + "\n" + + "attributes\x18\x06 \x03(\v2).sentry.logs.v1.LogRecord.AttributesEntryR\n" + + "attributes\x1a=\n" + + "\x0fAttributesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"b\n" + + "\x10PushBatchRequest\x12\x19\n" + + "\bbatch_id\x18\x01 \x01(\tR\abatchId\x123\n" + + "\arecords\x18\x02 \x03(\v2\x19.sentry.logs.v1.LogRecordR\arecords\"/\n" + + "\x11PushBatchResponse\x12\x1a\n" + + "\baccepted\x18\x01 \x01(\rR\baccepted*\x9a\x01\n" + + "\bSeverity\x12\x18\n" + + "\x14SEVERITY_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eSEVERITY_TRACE\x10\x01\x12\x12\n" + + "\x0eSEVERITY_DEBUG\x10\x02\x12\x11\n" + + "\rSEVERITY_INFO\x10\x03\x12\x11\n" + + "\rSEVERITY_WARN\x10\x04\x12\x12\n" + + "\x0eSEVERITY_ERROR\x10\x05\x12\x12\n" + + "\x0eSEVERITY_FATAL\x10\x062]\n" + + "\tLogIngest\x12P\n" + + "\tPushBatch\x12 .sentry.logs.v1.PushBatchRequest\x1a!.sentry.logs.v1.PushBatchResponseB6Z4github.com/sentry/sentry/proto/sentry/logs/v1;logsv1b\x06proto3" + +var ( + file_sentry_logs_v1_logs_proto_rawDescOnce sync.Once + file_sentry_logs_v1_logs_proto_rawDescData []byte +) + +func file_sentry_logs_v1_logs_proto_rawDescGZIP() []byte { + file_sentry_logs_v1_logs_proto_rawDescOnce.Do(func() { + file_sentry_logs_v1_logs_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_sentry_logs_v1_logs_proto_rawDesc), len(file_sentry_logs_v1_logs_proto_rawDesc))) + }) + return file_sentry_logs_v1_logs_proto_rawDescData +} + +var file_sentry_logs_v1_logs_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_sentry_logs_v1_logs_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_sentry_logs_v1_logs_proto_goTypes = []any{ + (Severity)(0), // 0: sentry.logs.v1.Severity + (*LogRecord)(nil), // 1: sentry.logs.v1.LogRecord + (*PushBatchRequest)(nil), // 2: sentry.logs.v1.PushBatchRequest + (*PushBatchResponse)(nil), // 3: sentry.logs.v1.PushBatchResponse + nil, // 4: sentry.logs.v1.LogRecord.AttributesEntry +} +var file_sentry_logs_v1_logs_proto_depIdxs = []int32{ + 0, // 0: sentry.logs.v1.LogRecord.severity:type_name -> sentry.logs.v1.Severity + 4, // 1: sentry.logs.v1.LogRecord.attributes:type_name -> sentry.logs.v1.LogRecord.AttributesEntry + 1, // 2: sentry.logs.v1.PushBatchRequest.records:type_name -> sentry.logs.v1.LogRecord + 2, // 3: sentry.logs.v1.LogIngest.PushBatch:input_type -> sentry.logs.v1.PushBatchRequest + 3, // 4: sentry.logs.v1.LogIngest.PushBatch:output_type -> sentry.logs.v1.PushBatchResponse + 4, // [4:5] is the sub-list for method output_type + 3, // [3:4] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_sentry_logs_v1_logs_proto_init() } +func file_sentry_logs_v1_logs_proto_init() { + if File_sentry_logs_v1_logs_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_sentry_logs_v1_logs_proto_rawDesc), len(file_sentry_logs_v1_logs_proto_rawDesc)), + NumEnums: 1, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_sentry_logs_v1_logs_proto_goTypes, + DependencyIndexes: file_sentry_logs_v1_logs_proto_depIdxs, + EnumInfos: file_sentry_logs_v1_logs_proto_enumTypes, + MessageInfos: file_sentry_logs_v1_logs_proto_msgTypes, + }.Build() + File_sentry_logs_v1_logs_proto = out.File + file_sentry_logs_v1_logs_proto_goTypes = nil + file_sentry_logs_v1_logs_proto_depIdxs = nil +} diff --git a/proto/sentry/logs/v1/logs.proto b/proto/sentry/logs/v1/logs.proto new file mode 100644 index 0000000..28ce4c2 --- /dev/null +++ b/proto/sentry/logs/v1/logs.proto @@ -0,0 +1,68 @@ +syntax = "proto3"; + +package sentry.logs.v1; + +option go_package = "github.com/sentry/sentry/proto/sentry/logs/v1;logsv1"; + +// LogIngest is the service agents use to ship batched log records to the +// ingest service over mTLS. Phase 0: single unary batch push. Streaming +// (client-streaming for continuous shipping) is a likely Phase 1 upgrade +// once backpressure/flow-control behavior is characterized. +service LogIngest { + rpc PushBatch(PushBatchRequest) returns (PushBatchResponse); +} + +// Severity follows OTel's severity number ranges (1-24), collapsed here to +// the coarse names agents actually need to set. Numeric value stored +// downstream may be a full OTel severity_number computed by ingest. +enum Severity { + SEVERITY_UNSPECIFIED = 0; + SEVERITY_TRACE = 1; + SEVERITY_DEBUG = 2; + SEVERITY_INFO = 3; + SEVERITY_WARN = 4; + SEVERITY_ERROR = 5; + SEVERITY_FATAL = 6; +} + +message LogRecord { + // Unix epoch nanoseconds, set by the agent at time of read (not parse or + // send time) to preserve original ordering as closely as possible. + int64 timestamp_unix_nano = 1; + + // Hostname the agent is running on. Agent fills this from its own config + // or system hostname; not trusted as an identity claim (mTLS client cert + // is the identity boundary). + string host = 2; + + // Logical service/unit name. For journald sources, this is typically the + // systemd unit name; for file sources, it comes from agent config. + string service = 3; + + Severity severity = 4; + + // Original, unparsed log line. Always populated, even when structured + // fields below are also present, per the schema-on-read fallback + // requirement in CLAUDE.md. + string message = 5; + + // Structured fields extracted by the agent's parser (e.g. RFC 5424 + // syslog header fields). Empty when the raw-passthrough fallback fires. + map attributes = 6; +} + +message PushBatchRequest { + // Agent-assigned identifier for dedup/idempotency on retry. Ingest may + // use this to avoid double-writing a batch if a retry follows a + // timeout on an actually-successful push. + string batch_id = 1; + + repeated LogRecord records = 2; +} + +message PushBatchResponse { + // Number of records ingest accepted. Phase 0: batches are all-or-nothing, + // so this equals len(records) on success. Partial-acceptance semantics + // are not implemented yet. + uint32 accepted = 1; +} diff --git a/proto/sentry/logs/v1/logs_grpc.pb.go b/proto/sentry/logs/v1/logs_grpc.pb.go new file mode 100644 index 0000000..cf728e8 --- /dev/null +++ b/proto/sentry/logs/v1/logs_grpc.pb.go @@ -0,0 +1,131 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc v7.35.1 +// source: sentry/logs/v1/logs.proto + +package logsv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + LogIngest_PushBatch_FullMethodName = "/sentry.logs.v1.LogIngest/PushBatch" +) + +// LogIngestClient is the client API for LogIngest service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// LogIngest is the service agents use to ship batched log records to the +// ingest service over mTLS. Phase 0: single unary batch push. Streaming +// (client-streaming for continuous shipping) is a likely Phase 1 upgrade +// once backpressure/flow-control behavior is characterized. +type LogIngestClient interface { + PushBatch(ctx context.Context, in *PushBatchRequest, opts ...grpc.CallOption) (*PushBatchResponse, error) +} + +type logIngestClient struct { + cc grpc.ClientConnInterface +} + +func NewLogIngestClient(cc grpc.ClientConnInterface) LogIngestClient { + return &logIngestClient{cc} +} + +func (c *logIngestClient) PushBatch(ctx context.Context, in *PushBatchRequest, opts ...grpc.CallOption) (*PushBatchResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PushBatchResponse) + err := c.cc.Invoke(ctx, LogIngest_PushBatch_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// LogIngestServer is the server API for LogIngest service. +// All implementations must embed UnimplementedLogIngestServer +// for forward compatibility. +// +// LogIngest is the service agents use to ship batched log records to the +// ingest service over mTLS. Phase 0: single unary batch push. Streaming +// (client-streaming for continuous shipping) is a likely Phase 1 upgrade +// once backpressure/flow-control behavior is characterized. +type LogIngestServer interface { + PushBatch(context.Context, *PushBatchRequest) (*PushBatchResponse, error) + mustEmbedUnimplementedLogIngestServer() +} + +// UnimplementedLogIngestServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedLogIngestServer struct{} + +func (UnimplementedLogIngestServer) PushBatch(context.Context, *PushBatchRequest) (*PushBatchResponse, error) { + return nil, status.Error(codes.Unimplemented, "method PushBatch not implemented") +} +func (UnimplementedLogIngestServer) mustEmbedUnimplementedLogIngestServer() {} +func (UnimplementedLogIngestServer) testEmbeddedByValue() {} + +// UnsafeLogIngestServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to LogIngestServer will +// result in compilation errors. +type UnsafeLogIngestServer interface { + mustEmbedUnimplementedLogIngestServer() +} + +func RegisterLogIngestServer(s grpc.ServiceRegistrar, srv LogIngestServer) { + // If the following call panics, it indicates UnimplementedLogIngestServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&LogIngest_ServiceDesc, srv) +} + +func _LogIngest_PushBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PushBatchRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(LogIngestServer).PushBatch(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: LogIngest_PushBatch_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(LogIngestServer).PushBatch(ctx, req.(*PushBatchRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// LogIngest_ServiceDesc is the grpc.ServiceDesc for LogIngest service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var LogIngest_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "sentry.logs.v1.LogIngest", + HandlerType: (*LogIngestServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "PushBatch", + Handler: _LogIngest_PushBatch_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "sentry/logs/v1/logs.proto", +} diff --git a/storage/Dockerfile b/storage/Dockerfile new file mode 100644 index 0000000..1dbfde2 --- /dev/null +++ b/storage/Dockerfile @@ -0,0 +1,10 @@ +# One-shot migration runner: bash + curl baked in, migrations/*.sql copied +# in at build time. No runtime package install and no host volume mount +# needed — works offline once built. +# docker build -f storage/Dockerfile -t sentry-clickhouse-migrate storage/ +FROM alpine:3.20 +RUN apk add --no-cache bash curl +WORKDIR /storage +COPY migrate.sh ./ +COPY migrations ./migrations +ENTRYPOINT ["bash", "migrate.sh"] diff --git a/storage/README.md b/storage/README.md new file mode 100644 index 0000000..f7cc397 --- /dev/null +++ b/storage/README.md @@ -0,0 +1,84 @@ +# storage + +ClickHouse schema and migration tooling for Sentry's analytical store. + +## Schema + +One table for Phase 0, `logs`: + +```sql +CREATE TABLE logs +( + `timestamp` DateTime64(9, 'UTC'), + `host` String, + `service` String, + `severity` LowCardinality(String), + `message` String, + `attributes` Map(String, String) +) +ENGINE = MergeTree +PARTITION BY toDate(timestamp) +ORDER BY (service, timestamp) +``` + +Notes on choices that weren't fully specified by the task description: + +- **`DateTime64(9, 'UTC')`** (nanosecond precision) rather than second or + millisecond precision, to match the agent's `timestamp_unix_nano` field + end to end without truncation. +- **`severity` as `LowCardinality(String)`**, not a numeric OTel + `SeverityNumber`. `/ingest`'s `normalize` package writes short text + values (`TRACE`/`DEBUG`/`INFO`/`WARN`/`ERROR`/`FATAL`/`UNSPECIFIED`). + `LowCardinality` gets you most of the storage/query efficiency of an enum + without committing to one at the schema level. Splitting into a proper + `SeverityNumber` + `SeverityText` pair (full OTel shape) is one of the + open questions already flagged in `/docs/architecture.md`. +- **`PARTITION BY toDate(timestamp)`** (daily partitions) and + **`ORDER BY (service, timestamp)`** are exactly what the task asked for + — service-scoped queries over a time range are the dominant access + pattern this is optimized for. +- No TTL/retention clause yet — also an open question in architecture.md, + deferred until storage sizing is a real concern. + +## Migration tooling: a plain SQL-file runner, not golang-migrate + +`migrate.sh` applies `migrations/*.sql` in filename order over +ClickHouse's HTTP interface, tracking what's applied in a +`schema_migrations` table. Chosen over `golang-migrate` for Phase 0 +because there's exactly one migration to run — pulling in a migration +framework (another dependency, another thing to configure/vendor) for a +single `CREATE TABLE` is exactly the kind of premature machinery this +project's conventions say to avoid. Revisit `golang-migrate` once there's +real schema churn across environments (rollback support, checksums, +concurrent-apply safety become worth their cost at that point, not before). + +**Convention:** one DDL statement per migration file. The ClickHouse HTTP +interface isn't reliably multi-statement, so `migrate.sh` doesn't try to +split multi-statement files — keep each migration to a single statement. + +## Running + +```sh +docker compose up -d # starts a standalone ClickHouse for local work +./migrate.sh # applies migrations/*.sql +``` + +Environment variables `migrate.sh` reads (all optional, matching +`/ingest`'s ClickHouse defaults so the two stay in sync out of the box): + +| Var | Default | +|---|---| +| `CLICKHOUSE_HTTP` | `http://localhost:8123` | +| `CLICKHOUSE_USER` | `default` | +| `CLICKHOUSE_PASSWORD` | (empty) | +| `CLICKHOUSE_DATABASE` | `sentry` | + +There's also a `Dockerfile` (bash + curl baked in, `migrations/` copied in +at build time) used by the root-level `docker-compose.yml` as a one-shot +init service — no runtime package install, no host volume mount needed. + +## Adding a migration + +Add `migrations/000N_description.sql` with the next sequential number and +a single DDL statement. `migrate.sh` picks it up automatically — no +registration step. diff --git a/storage/docker-compose.yml b/storage/docker-compose.yml new file mode 100644 index 0000000..2bfe576 --- /dev/null +++ b/storage/docker-compose.yml @@ -0,0 +1,20 @@ +# Standalone ClickHouse for local development against /storage in +# isolation (e.g. iterating on migrations). The root-level docker-compose.yml +# runs the full Phase 0 stack and defines its own clickhouse service +# separately — this file is not included by it. +services: + clickhouse: + image: clickhouse/clickhouse-server:24.8 + container_name: sentry-clickhouse + ports: + - "8123:8123" # HTTP interface, used by migrate.sh + - "9000:9000" # native protocol, used by ingest + volumes: + - clickhouse-data:/var/lib/clickhouse + ulimits: + nofile: + soft: 262144 + hard: 262144 + +volumes: + clickhouse-data: diff --git a/storage/migrate.sh b/storage/migrate.sh new file mode 100755 index 0000000..155071f --- /dev/null +++ b/storage/migrate.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Applies migrations/*.sql to ClickHouse in filename order, tracking what's +# already been applied in a schema_migrations table. Talks to ClickHouse's +# HTTP interface via curl rather than requiring the clickhouse-client +# binary — nothing to install beyond curl, works identically on a dev +# laptop or in CI. +# +# Convention: exactly one DDL statement per migration file. The ClickHouse +# HTTP interface isn't reliably multi-statement, so keeping migrations to +# one statement each avoids relying on that. +set -euo pipefail + +CLICKHOUSE_HTTP="${CLICKHOUSE_HTTP:-http://localhost:8123}" +CLICKHOUSE_USER="${CLICKHOUSE_USER:-default}" +CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-}" +DATABASE="${CLICKHOUSE_DATABASE:-sentry}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MIGRATIONS_DIR="${SCRIPT_DIR}/migrations" + +ch_exec() { + # $1 = SQL statement, $2 = optional database to scope the query to. + local sql="$1" + local db="${2:-}" + local url="${CLICKHOUSE_HTTP}/" + if [[ -n "$db" ]]; then + url="${CLICKHOUSE_HTTP}/?database=${db}" + fi + curl -sS -f -u "${CLICKHOUSE_USER}:${CLICKHOUSE_PASSWORD}" "$url" --data-binary "$sql" +} + +echo "Ensuring database '${DATABASE}' exists..." +ch_exec "CREATE DATABASE IF NOT EXISTS ${DATABASE}" + +echo "Ensuring schema_migrations table exists..." +ch_exec "CREATE TABLE IF NOT EXISTS schema_migrations (version String, applied_at DateTime DEFAULT now()) ENGINE = MergeTree ORDER BY version" "$DATABASE" + +applied="$(ch_exec "SELECT version FROM schema_migrations FORMAT TabSeparated" "$DATABASE")" + +shopt -s nullglob +for file in "${MIGRATIONS_DIR}"/*.sql; do + version="$(basename "$file")" + if grep -qx "$version" <<< "$applied"; then + echo "skip ${version} (already applied)" + continue + fi + echo "apply ${version}" + ch_exec "$(cat "$file")" "$DATABASE" > /dev/null + ch_exec "INSERT INTO schema_migrations (version) VALUES ('${version}')" "$DATABASE" > /dev/null +done + +echo "Migrations complete." diff --git a/storage/migrations/0001_create_logs_table.sql b/storage/migrations/0001_create_logs_table.sql new file mode 100644 index 0000000..83e33bb --- /dev/null +++ b/storage/migrations/0001_create_logs_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS logs +( + `timestamp` DateTime64(9, 'UTC'), + `host` String, + `service` String, + `severity` LowCardinality(String), + `message` String, + `attributes` Map(String, String) +) +ENGINE = MergeTree +PARTITION BY toDate(timestamp) +ORDER BY (service, timestamp) diff --git a/transport/Dockerfile b/transport/Dockerfile new file mode 100644 index 0000000..064af86 --- /dev/null +++ b/transport/Dockerfile @@ -0,0 +1,7 @@ +# Built FROM the Redpanda image so `rpk` is already present -- no need for +# a separate client install, and no Docker socket access needed since +# provisioning happens over the network, not via `docker exec`. +# docker build -f transport/Dockerfile -t sentry-transport-provision transport/ +FROM docker.redpanda.com/redpandadata/redpanda:v24.2.7 +COPY provision-topics.sh /provision-topics.sh +ENTRYPOINT ["/provision-topics.sh"] diff --git a/transport/README.md b/transport/README.md new file mode 100644 index 0000000..29776fa --- /dev/null +++ b/transport/README.md @@ -0,0 +1,26 @@ +# transport + +Redpanda for local development, plus the script that provisions the topic +`/ingest` depends on. + +## Topic naming contract + +`ingest` defaults to `REDPANDA_TOPIC=sentry.logs.raw` (see +`/ingest/internal/config`). `provision-topics.sh` defaults to the same +name. These aren't wired together automatically — if you change one, +change the other, or override `REDPANDA_TOPIC` consistently wherever +both are invoked. + +## Running standalone + +```sh +docker compose up -d +REDPANDA_BROKERS=localhost:9092 ./provision-topics.sh +``` + +## In the full stack + +The root-level `docker-compose.yml` builds this directory's `Dockerfile` +(FROM the Redpanda image itself, so `rpk` is already present) as a +one-shot init service that runs after Redpanda reports healthy. See +`/docs/phase-0-runbook.md`. diff --git a/transport/docker-compose.yml b/transport/docker-compose.yml new file mode 100644 index 0000000..3bf1a2b --- /dev/null +++ b/transport/docker-compose.yml @@ -0,0 +1,34 @@ +# Standalone Redpanda for local development against /transport in +# isolation. The root-level docker-compose.yml runs the full Phase 0 stack +# and defines its own redpanda service separately — this file is not +# included by it. +# +# Note advertise-kafka-addr is "localhost" here (host tools connect via the +# mapped port), vs "redpanda" in the root compose (other containers connect +# via the compose network's service DNS name). Getting this wrong is the +# classic Redpanda/Kafka docker-compose footgun — clients can connect +# initially but then fail on the broker's advertised address once they try +# to actually produce/consume. +services: + redpanda: + image: docker.redpanda.com/redpandadata/redpanda:v24.2.7 + container_name: sentry-redpanda + command: + - redpanda + - start + - --smp=1 + - --memory=1G + - --reserve-memory=0M + - --overprovisioned + - --node-id=0 + - --check=false + - --kafka-addr=PLAINTEXT://0.0.0.0:9092 + - --advertise-kafka-addr=PLAINTEXT://localhost:9092 + ports: + - "9092:9092" + - "9644:9644" # admin API, used by rpk/healthchecks + volumes: + - redpanda-data:/var/lib/redpanda/data + +volumes: + redpanda-data: diff --git a/transport/provision-topics.sh b/transport/provision-topics.sh new file mode 100755 index 0000000..ffcfafd --- /dev/null +++ b/transport/provision-topics.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Idempotently creates the topic ingest produces/consumes. Talks to +# Redpanda over the network via `rpk`, not `docker exec` into the broker +# container -- this way the same script works whether it's run from the +# host (against the standalone compose in this directory), from inside a +# sibling container on the root compose's network, or in CI. +set -euo pipefail + +BROKERS="${REDPANDA_BROKERS:-localhost:9092}" +TOPIC="${REDPANDA_TOPIC:-sentry.logs.raw}" +PARTITIONS="${REDPANDA_TOPIC_PARTITIONS:-6}" + +echo "Waiting for Redpanda at ${BROKERS}..." +until rpk cluster health --brokers "${BROKERS}" --exit-when-healthy > /dev/null 2>&1; do + sleep 1 +done + +if rpk topic list --brokers "${BROKERS}" | awk 'NR>1{print $1}' | grep -qx "${TOPIC}"; then + echo "Topic '${TOPIC}' already exists, skipping." +else + echo "Creating topic '${TOPIC}' (${PARTITIONS} partitions)..." + rpk topic create "${TOPIC}" --brokers "${BROKERS}" --partitions "${PARTITIONS}" --replicas 1 +fi diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..9199e72 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,4 @@ +# Base URL of the /api service. Baked into the static build at build time +# (this is a prerendered SPA, not a server) — set this before `npm run +# build` / `docker build`, not at container start. +VITE_API_BASE_URL=http://localhost:8080 diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/web/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/web/.vscode/extensions.json b/web/.vscode/extensions.json new file mode 100644 index 0000000..28d1e67 --- /dev/null +++ b/web/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..a3b96f5 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,24 @@ +# Build context can be just web/ (unlike agent/ingest/api, this doesn't +# need /proto): +# docker build -f web/Dockerfile -t sentry-web web/ + +FROM node:22-alpine AS builder +WORKDIR /src +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +# VITE_API_BASE_URL is baked in at build time — this is a prerendered +# static site, not a server. Override with --build-arg for non-default +# deployments. +ARG VITE_API_BASE_URL=http://localhost:8080 +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} +RUN npm run build + +# Not distroless: serving a static SPA needs *some* HTTP server, and +# nginx:alpine is the boring, well-understood choice for that job — a +# custom static-file-serving binary would be more engineering than a +# Phase 0 placeholder page warrants. See /web/README.md. +FROM nginx:alpine +COPY --from=builder /src/build /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 3000 diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..4c2dc5b --- /dev/null +++ b/web/README.md @@ -0,0 +1,49 @@ +# web + +SvelteKit frontend. Phase 0: one page, one query box, one table. No auth, +no styling polish, no routing beyond `/`. + +## What it does + +Textarea for a raw SQL string → `POST {VITE_API_BASE_URL}/query` on `/api` +→ renders `{columns, rows}` as an HTML table, or shows `{error}` from a +rejected/failed query. That's the whole app — see `src/routes/+page.svelte`. + +## Why a static build, not a Node server + +Scaffolded with `@sveltejs/adapter-static`: this page has no server-side +data loading (all data comes from a client-side `fetch` triggered by the +submit button), so there's nothing here that needs a running SvelteKit +server. A prerendered static site is simpler to build, deploy, and reason +about than running Node in production for a page that's this thin. + +Because it's static, `VITE_API_BASE_URL` is baked in at **build time**, not +read at container start. Set it before `npm run build` (or pass +`--build-arg VITE_API_BASE_URL=...` to `docker build`) — changing it later +means rebuilding, not just restarting the container. + +## Building & running + +```sh +npm install +cp .env.example .env # adjust VITE_API_BASE_URL if /api isn't on localhost:8080 +npm run dev # local dev server with hot reload +npm run check # svelte-check, type errors +npm run build # static output to build/ +npm run preview # serve the static build locally to sanity-check it +``` + +```sh +docker build -f Dockerfile -t sentry-web . # context is web/, not the repo root +docker run -p 3000:3000 sentry-web +``` + +## Why nginx, not distroless + +The repo convention prefers distroless/scratch base images. Serving a +static SPA still needs *some* HTTP server, though, and `nginx:alpine` is +the boring, standard choice for that job — writing a custom static-file +binary just to stay distroless would be more engineering than a Phase 0 +placeholder page justifies. `nginx.conf` here is minimal: serve `build/`, +fall back to `index.html` for client-side routing (only one route exists +today, but this is what you want the moment a second one is added). diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..b0c7699 --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,9 @@ +server { + listen 3000; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..1015e62 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1338 @@ +{ + "name": "web", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.1", + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "typescript": "^6.0.3", + "vite": "^8.0.16" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.2", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.2.tgz", + "integrity": "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.0.tgz", + "integrity": "sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte/node_modules/magic-string": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.0.tgz", + "integrity": "sha512-ptco+HFxTLgjafSLim2LojBSwfg5feBjd+SqyiwdGkzC38UPdZy3zgrHMI2CoTf5fJL38tbHMYWVzIH8BxGqJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", + "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.2.tgz", + "integrity": "sha512-40GyiEJevYKXzYTHtZkFqAgTjLOuFcaXMao8TPyOlnWTlkHDlvZ6mPMJaJyOqVwrVCgomEG1WhJd81w0X+IcCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.9", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.9.tgz", + "integrity": "sha512-VT8kSnlEg8069w7AiCcAk3Yf5xvMnrGTagVOmU/OpOLHaHnNqXhWZCH/4EVga/bT/HtWhvE6/fHrXLErx7OnJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..24dac41 --- /dev/null +++ b/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "web", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "typescript": "^6.0.3", + "vite": "^8.0.16" + } +} diff --git a/web/src/app.d.ts b/web/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/web/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 0000000..6a2bb58 --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/lib/assets/favicon.svg b/web/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/web/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/web/src/lib/index.ts b/web/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/web/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 0000000..8098bbb --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,12 @@ + + + + Sentry + + + +{@render children()} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 0000000..3164d03 --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,128 @@ + + +
+

Sentry — Log Query (Phase 0)

+

+ Raw SQL only, SELECT statements against the logs table. No auth, no query + builder yet — see /api for what's actually allowed. +

+ + +
+ +
+ + {#if error} +

Error: {error}

+ {/if} + + {#if hasRun && !error} +

{rows.length} row(s)

+ {/if} + + {#if columns.length > 0} + + + + {#each columns as col (col)} + + {/each} + + + + {#each rows as row, i (i)} + + {#each row as cell, j (j)} + + {/each} + + {/each} + +
{col}
{formatCell(cell)}
+ {/if} +
+ + diff --git a/web/src/routes/+page.ts b/web/src/routes/+page.ts new file mode 100644 index 0000000..143c3c3 --- /dev/null +++ b/web/src/routes/+page.ts @@ -0,0 +1,4 @@ +// Static adapter needs every route prerenderable. This page has no load +// function (all data comes from a client-side fetch on submit), so a plain +// prerender is enough — no need to disable SSR. +export const prerender = true; diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/static/robots.txt b/web/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/web/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..66f7358 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,15 @@ +import adapter from '@sveltejs/adapter-static'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + sveltekit({ + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true + }, + adapter: adapter() + }) + ] +});