Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee

feat: stats page and metrics

pdewey.com decf5a14 9922f628

verified
+2513 -31
+1
.cells/cells.jsonl
··· 16 16 {"id":"01KGDXVR86CB2H44DJHKN4Z9M0","title":"Implement comments for social interactions","description":"Add a comment lexicon and implement comment functionality across the app.\n\n## Lexicon: social.arabica.alpha.comment\n\nThe comment record should:\n- Use `com.atproto.repo.strongRef` for the subject (URI + CID for immutability)\n- Support commenting on any arabica.social lexicon type (beans, roasters, grinders, brewers, brews, and other comments)\n- Include text field (max 1000 chars / 300 graphemes as per AT Protocol conventions)\n- Include createdAt timestamp\n- Use TID as record key\n- NOTE: This cell implements flat comments only. Threaded/nested comments are handled in a separate cell.\n\n## Backend Implementation\n\n1. Create lexicon file: `lexicons/social.arabica.alpha.comment.json`\n2. Add NSID constant in `internal/atproto/nsid.go`\n3. Add Comment model in `internal/models/models.go`\n4. Add record conversion functions in `internal/atproto/records.go`\n5. Add Store interface methods: CreateComment, DeleteComment, GetCommentsForSubject, GetUserComments\n6. Implement in AtprotoStore\n7. Update firehose indexing to track comments\n\n## Frontend Implementation\n\n1. Add comment section component below brew detail view\n2. Add comment form (authenticated users only)\n3. Display comment count on records in feed\n4. Update comment counts in response to firehose events\n5. Show commenter profile info (avatar, handle)\n\n## Acceptance Criteria\n\n- Users can comment on any arabica.social record\n- Comments are stored in the user's PDS (actor-owned data)\n- Comment counts display on records in the feed\n- Comments visible on record detail views\n- Real-time comment count updates via firehose","status":"open","priority":"normal","labels":["backend","frontend","atproto"],"created_at":"2026-02-02T01:00:51.846427126Z","updated_at":"2026-02-02T01:00:51.846427126Z"} 17 17 {"id":"01KGDXW31PHFMBE3WTV83HPET1","title":"Implement comment threading","description":"Add support for threaded/nested comments, building on the flat comment system.\n\n## Lexicon Changes\n\nUpdate `social.arabica.alpha.comment` to add optional threading fields:\n- `parent`: Optional strongRef to parent comment (for replies)\n- `root`: Optional strongRef to root subject (maintains context when replying to comments)\n\nAlternatively, consider a separate reply field or keeping comments flat with UI-level threading.\n\n## Backend Implementation\n\n1. Update comment model to include parent/root references\n2. Add methods to fetch comment threads: GetCommentThread, GetReplies\n3. Update firehose indexing to track parent-child relationships\n4. Add depth limits for threading (prevent infinite nesting)\n\n## Frontend Implementation\n\n1. Design threaded comment UI (indentation, collapse/expand)\n2. Add 'reply' button on comments\n3. Show reply context when replying\n4. Consider max nesting depth for display (e.g., 3-4 levels)\n5. Mobile-friendly thread navigation\n\n## Design Considerations\n\n- How deep should threading go? (Recommend max 3-4 levels visible, then flatten)\n- How to handle deleted parent comments?\n- Should users be notified when someone replies to their comment?\n- Performance: lazy-load deep threads vs eager-load\n\n## Acceptance Criteria\n\n- Users can reply directly to comments\n- Thread structure is visually clear\n- Threads can be collapsed/expanded\n- Works well on mobile devices\n- Parent context shown when replying","status":"blocked","priority":"low","blocked_by":["01KGDXVR86CB2H44DJHKN4Z9M0"],"labels":["frontend","atproto"],"created_at":"2026-02-02T01:01:02.902692829Z","updated_at":"2026-02-02T01:01:06.361891137Z"} 18 18 {"id":"01KJ36P2BWM4HSZV6GNX0BJB1F","title":"Implement lightweight For You feed algorithm","description":"Add a 'For You' algorithmic feed tab alongside the existing chronological feed. This should be a lightweight scoring system that ranks posts based on:\n\n**Scoring Factors:**\n1. **Engagement score**: likes (weight 3x) + comments (weight 2x) on the post\n2. **Time decay**: Score multiplied by a decay factor based on post age. Use exponential decay with a half-life of ~24 hours so recent engaged content surfaces while popular older content still has a chance.\n3. **Type diversity**: After scoring, apply a diversity pass to avoid showing too many of the same record type in a row. If 3+ consecutive items are the same type, interleave with the next different-type item.\n4. **Social proximity** (future): Boost posts from users the viewer has interacted with (liked their content, commented on their posts). This requires building a per-user interaction graph from the like/comment indexes.\n\n**Implementation approach:**\n- Add a new `FeedSortForYou` sort option alongside recent/popular\n- Add a `scoreForYouItem(item *FeedItem, viewerDID string) float64` function in the firehose index\n- Fetch ~100 recent items, score them, apply diversity, return top N\n- Add a 'For You' tab in the feed filter bar UI (only for authenticated users)\n- Cache scored results per-viewer with short TTL (1-2 min) to avoid re-scoring on pagination\n\n**Key files:**\n- `internal/firehose/index.go` - Add scoring logic and ForYou query\n- `internal/feed/service.go` - Add FeedSortForYou constant\n- `internal/firehose/adapter.go` - Pass through ForYou sort\n- `internal/handlers/feed.go` - Handle sort=foryou param\n- `internal/web/pages/feed.templ` - Add For You tab\n\n**Dependencies:**\n- Relies on existing BucketByTime, BucketLikeCounts, BucketCommentCounts, BucketLikesByActor indexes\n- Social proximity scoring depends on being able to query BucketLikesByActor efficiently","status":"open","priority":"normal","created_at":"2026-02-22T17:34:47.67684351Z","updated_at":"2026-02-22T17:34:47.67684351Z"} 19 + {"id":"01KJ37P3RD4X4P7B8F5EWE6NWB","title":"Add Prometheus metrics endpoint","description":"Add prometheus/client_golang instrumentation to Arabica with a /metrics endpoint. Minimal but useful instrumentation points:\n\n## Metrics to implement\n\n### HTTP middleware (internal/middleware/)\n- `arabica_http_requests_total` counter: labels: method, path, status\n- `arabica_http_request_duration_seconds` histogram: labels: method, path\n\n### Firehose consumer (internal/firehose/)\n- `arabica_firehose_events_total` counter: labels: collection, operation\n- `arabica_firehose_connection_state` gauge: 1=connected, 0=disconnected\n- `arabica_firehose_errors_total` counter\n\n### PDS client (internal/atproto/)\n- `arabica_pds_requests_total` counter: labels: method, collection\n- `arabica_pds_request_duration_seconds` histogram: labels: method\n- `arabica_pds_errors_total` counter: labels: method\n\n### Feed service (internal/feed/)\n- `arabica_feed_cache_hits_total` counter\n- `arabica_feed_cache_misses_total` counter\n\n## Implementation notes\n- Add `github.com/prometheus/client_golang` dependency\n- Expose /metrics endpoint in routing.go (no auth required)\n- Keep cardinality low: normalize path labels (e.g. /brews/{id} -\u003e /brews/:id)\n- Create a grafana/arabica-prometheus.json dashboard alongside the existing log-based one\n- Existing log-based dashboard is at grafana/arabica-logs.json for reference","status":"completed","priority":"normal","assignee":"patrick","labels":["backend","observability"],"created_at":"2026-02-22T17:52:17.677417112Z","updated_at":"2026-02-22T17:57:56.326244513Z","completed_at":"2026-02-22T17:57:56.315753758Z"}
+1
CLAUDE.md
··· 450 450 | `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path | 451 451 | `ARABICA_MODERATORS_CONFIG` | - | Path to moderators JSON config | 452 452 | `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration | 453 + | `METRICS_PORT` | 9101 | Internal metrics server port (localhost only) | 453 454 | `SECURE_COOKIES` | false | Set true for HTTPS | 454 455 | `LOG_LEVEL` | info | debug/info/warn/error | 455 456 | `LOG_FORMAT` | console | console/json |
+45 -1
cmd/server/main.go
··· 20 20 "arabica/internal/feed" 21 21 "arabica/internal/firehose" 22 22 "arabica/internal/handlers" 23 + "arabica/internal/metrics" 23 24 "arabica/internal/moderation" 24 25 "arabica/internal/routing" 25 26 27 + "github.com/prometheus/client_golang/prometheus/promhttp" 26 28 "github.com/rs/zerolog" 27 29 "github.com/rs/zerolog/log" 28 30 ) ··· 193 195 194 196 log.Info().Msg("Firehose consumer started") 195 197 198 + // Start metrics collector for periodic gauge updates 199 + metrics.StartCollector(ctx, metrics.StatsSource{ 200 + KnownDIDCount: feedIndex.KnownDIDCount, 201 + RegisteredCount: feedRegistry.Count, 202 + RecordCount: feedIndex.RecordCount, 203 + PendingJoinCount: func() int { 204 + joinStore := store.JoinStore() 205 + if reqs, err := joinStore.ListRequests(); err == nil { 206 + return len(reqs) 207 + } 208 + return 0 209 + }, 210 + LikeCount: feedIndex.TotalLikeCount, 211 + CommentCount: feedIndex.TotalCommentCount, 212 + RecordCountByCollection: feedIndex.RecordCountByCollection, 213 + FirehoseConnected: firehoseConsumer.IsConnected, 214 + }, 60*time.Second) 215 + 196 216 // Log known DIDs from database (DIDs discovered via firehose) 197 217 if knownDIDsFromDB, err := feedIndex.GetKnownDIDs(); err == nil { 198 218 if len(knownDIDsFromDB) > 0 { ··· 344 364 Logger: log.Logger, 345 365 }) 346 366 367 + // Start internal metrics server on localhost only (not publicly accessible) 368 + metricsPort := os.Getenv("METRICS_PORT") 369 + if metricsPort == "" { 370 + metricsPort = "9101" 371 + } 372 + metricsMux := http.NewServeMux() 373 + metricsMux.Handle("GET /metrics", promhttp.Handler()) 374 + metricsServer := &http.Server{ 375 + Addr: "127.0.0.1:" + metricsPort, 376 + Handler: metricsMux, 377 + } 378 + go func() { 379 + log.Info(). 380 + Str("address", "127.0.0.1:"+metricsPort). 381 + Msg("Starting metrics server (localhost only)") 382 + if err := metricsServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { 383 + log.Error().Err(err).Msg("Metrics server failed to start") 384 + } 385 + }() 386 + 347 387 // Create HTTP server 348 388 server := &http.Server{ 349 389 Addr: "0.0.0.0:" + port, ··· 372 412 log.Info().Msg("Stopping firehose consumer...") 373 413 firehoseConsumer.Stop() 374 414 375 - // Graceful shutdown of HTTP server 415 + // Graceful shutdown of HTTP server and metrics server 376 416 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) 377 417 defer shutdownCancel() 418 + 419 + if err := metricsServer.Shutdown(shutdownCtx); err != nil { 420 + log.Error().Err(err).Msg("Metrics server shutdown error") 421 + } 378 422 379 423 if err := server.Shutdown(shutdownCtx); err != nil { 380 424 log.Error().Err(err).Msg("HTTP server shutdown error")
+1 -1
default.nix
··· 4 4 pname = "arabica"; 5 5 version = "0.1.0"; 6 6 src = ./.; 7 - vendorHash = "sha256-CD6i6qocJ2E5TK7Xprw4bBYmAfZKcy0vMi/krKaMTS8="; 7 + vendorHash = "sha256-wxCDD46vgdpey8PyeoC2kb7Qd87MN3b+WCsywhpA8RM="; 8 8 9 9 nativeBuildInputs = [ templ tailwindcss ]; 10 10
+10 -9
go.mod
··· 5 5 require ( 6 6 github.com/a-h/templ v0.3.977 7 7 github.com/bluesky-social/indigo v0.0.0-20260106221649-6fcd9317e725 8 + github.com/google/go-querystring v1.1.0 8 9 github.com/gorilla/websocket v1.5.3 9 10 github.com/klauspost/compress v1.18.3 11 + github.com/prometheus/client_golang v1.23.2 12 + github.com/prometheus/client_model v0.6.2 10 13 github.com/rs/zerolog v1.34.0 11 - github.com/stretchr/testify v1.10.0 14 + github.com/stretchr/testify v1.11.1 12 15 go.etcd.io/bbolt v1.3.8 13 16 golang.org/x/sync v0.19.0 14 17 ) 15 18 16 19 require ( 17 20 github.com/beorn7/perks v1.0.1 // indirect 18 - github.com/cespare/xxhash/v2 v2.2.0 // indirect 21 + github.com/cespare/xxhash/v2 v2.3.0 // indirect 19 22 github.com/davecgh/go-spew v1.1.1 // indirect 20 23 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 21 24 github.com/felixge/httpsnoop v1.0.4 // indirect ··· 23 26 github.com/go-logr/stdr v1.2.2 // indirect 24 27 github.com/gogo/protobuf v1.3.2 // indirect 25 28 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 26 - github.com/google/go-querystring v1.1.0 // indirect 27 29 github.com/google/uuid v1.4.0 // indirect 28 30 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 29 31 github.com/hashicorp/go-retryablehttp v0.7.5 // indirect ··· 45 47 github.com/klauspost/cpuid/v2 v2.2.7 // indirect 46 48 github.com/mattn/go-colorable v0.1.13 // indirect 47 49 github.com/mattn/go-isatty v0.0.20 // indirect 48 - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 49 50 github.com/minio/sha256-simd v1.0.1 // indirect 50 51 github.com/mr-tron/base58 v1.2.0 // indirect 51 52 github.com/multiformats/go-base32 v0.1.0 // indirect ··· 53 54 github.com/multiformats/go-multibase v0.2.0 // indirect 54 55 github.com/multiformats/go-multihash v0.2.3 // indirect 55 56 github.com/multiformats/go-varint v0.0.7 // indirect 57 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 56 58 github.com/opentracing/opentracing-go v1.2.0 // indirect 57 59 github.com/pmezard/go-difflib v1.0.0 // indirect 58 60 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 59 - github.com/prometheus/client_golang v1.17.0 // indirect 60 - github.com/prometheus/client_model v0.5.0 // indirect 61 - github.com/prometheus/common v0.45.0 // indirect 62 - github.com/prometheus/procfs v0.12.0 // indirect 61 + github.com/prometheus/common v0.66.1 // indirect 62 + github.com/prometheus/procfs v0.16.1 // indirect 63 63 github.com/spaolacci/murmur3 v1.1.0 // indirect 64 64 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 65 65 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect ··· 71 71 go.uber.org/atomic v1.11.0 // indirect 72 72 go.uber.org/multierr v1.11.0 // indirect 73 73 go.uber.org/zap v1.26.0 // indirect 74 + go.yaml.in/yaml/v2 v2.4.2 // indirect 74 75 golang.org/x/crypto v0.40.0 // indirect 75 76 golang.org/x/sys v0.36.0 // indirect 76 77 golang.org/x/time v0.3.0 // indirect 77 78 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 78 - google.golang.org/protobuf v1.33.0 // indirect 79 + google.golang.org/protobuf v1.36.8 // indirect 79 80 gopkg.in/yaml.v3 v3.0.1 // indirect 80 81 lukechampine.com/blake3 v1.2.1 // indirect 81 82 )
+24 -20
go.sum
··· 6 6 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 7 7 github.com/bluesky-social/indigo v0.0.0-20260106221649-6fcd9317e725 h1:gfrLAhE6PHun4MDypO/5hpnaHPd9Dbe9+JxZL0gC4ic= 8 8 github.com/bluesky-social/indigo v0.0.0-20260106221649-6fcd9317e725/go.mod h1:KIy0FgNQacp4uv2Z7xhNkV3qZiUSGuRky97s7Pa4v+o= 9 - github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 10 - github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 9 + github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 10 + github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 11 11 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 12 12 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 13 13 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= ··· 29 29 github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 30 30 github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 31 31 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 32 - github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 33 - github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 32 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 33 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 34 34 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 35 35 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 36 36 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= ··· 95 95 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 96 96 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 97 97 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 98 + github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 99 + github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 98 100 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 99 101 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 100 102 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= ··· 102 104 github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 103 105 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 104 106 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 105 - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= 106 - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= 107 107 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 108 108 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 109 109 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= ··· 118 118 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 119 119 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 120 120 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 121 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 122 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 121 123 github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 122 124 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 123 125 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= ··· 126 128 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 127 129 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 128 130 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 129 - github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= 130 - github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= 131 - github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 132 - github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 133 - github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= 134 - github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= 135 - github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 136 - github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 131 + github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= 132 + github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 133 + github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 134 + github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 135 + github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= 136 + github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= 137 + github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 138 + github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 137 139 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 138 140 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 139 141 github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= ··· 153 155 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 154 156 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 155 157 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 156 - github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 157 - github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 158 + github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 159 + github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 158 160 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 159 161 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 160 162 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= ··· 182 184 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 183 185 go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 184 186 go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 185 - go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= 186 - go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 187 + go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 188 + go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 187 189 go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 188 190 go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 189 191 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= ··· 193 195 go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= 194 196 go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 195 197 go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 198 + go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= 199 + go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= 196 200 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 197 201 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 198 202 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= ··· 250 254 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 251 255 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 252 256 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 253 - google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 254 - google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 257 + google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= 258 + google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 255 259 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 256 260 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 257 261 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+50
grafana/README.md
··· 1 + # Grafana Dashboards 2 + 3 + Importable Grafana dashboard definitions for monitoring Arabica. 4 + 5 + ## Dashboards 6 + 7 + ### `arabica-logs.json` - Log-Based Metrics 8 + 9 + Queries structured JSON logs via **Loki**. No code changes needed - works with existing zerolog output. 10 + 11 + **Prerequisite:** Ship Arabica logs to Loki (e.g., via Promtail, Alloy, or Docker log driver). Logs must be in JSON format (`LOG_FORMAT=json`). 12 + 13 + **Log selector:** The dashboard uses a template variable (`$log_selector`) with three presets: 14 + 15 + - `unit="arabica.service"` (default) - NixOS/systemd journal via Promtail 16 + - `syslog_identifier="arabica"` - journald syslog identifier 17 + - `app="arabica"` - Docker log driver or custom labels 18 + 19 + Select the matching option from the dropdown at the top of the dashboard, or type a custom value. 20 + 21 + **Sections:** 22 + 23 + - **Overview** - stat panels for total requests, errors, logins, reports, join requests 24 + - **HTTP Traffic** - requests by status/method, top paths, response latency 25 + - **Firehose** - events by collection/operation, errors, backfill activity 26 + - **Authentication & Users** - login success/failure, join requests, invites 27 + - **Moderation** - reports, hide/unhide/block actions, permission denials 28 + - **PDS & ATProto** - PDS request volume/latency/errors by method and collection 29 + - **Errors & Warnings** - error/warn timeline + recent error log viewer 30 + 31 + ### `arabica-prometheus.json` - Prometheus Metrics 32 + 33 + Queries instrumented Prometheus counters, histograms, and gauges exposed at `/metrics`. 34 + 35 + **Prerequisite:** Arabica exposes a `/metrics` endpoint (Prometheus format). Configure Prometheus to scrape it. 36 + 37 + **Sections:** 38 + 39 + - **Overview** - request rate, error rate, p95 latency, firehose connection, events/s, cache hit rate 40 + - **HTTP Traffic** - request rate by status/path, latency percentiles (p50/p95/p99), latency by path 41 + - **Firehose** - events by collection/operation, error rate, connection state 42 + - **PDS / ATProto** - PDS request rate by method/collection, latency by method, error rate 43 + - **Feed Cache** - cache hits vs misses, hit rate over time 44 + 45 + ### Importing 46 + 47 + 1. In Grafana, go to **Dashboards > Import** 48 + 2. Upload the JSON file or paste its contents 49 + 3. Select your data source (Loki or Prometheus) when prompted 50 + 4. For the Loki dashboard, select the correct log selector from the dropdown (defaults to `unit="arabica.service"` for NixOS systemd)
+933
grafana/arabica-logs.json
··· 1 + { 2 + "__inputs": [ 3 + { 4 + "name": "DS_LOKI", 5 + "label": "Loki", 6 + "description": "Loki data source for Arabica logs", 7 + "type": "datasource", 8 + "pluginId": "loki", 9 + "pluginName": "Loki" 10 + } 11 + ], 12 + "annotations": { 13 + "list": [ 14 + { 15 + "builtIn": 1, 16 + "datasource": { "type": "grafana", "uid": "-- Grafana --" }, 17 + "enable": true, 18 + "hide": true, 19 + "iconColor": "rgba(0, 211, 255, 1)", 20 + "name": "Annotations & Alerts", 21 + "type": "dashboard" 22 + } 23 + ] 24 + }, 25 + "editable": true, 26 + "fiscalYearStartMonth": 0, 27 + "graphTooltip": 1, 28 + "id": null, 29 + "links": [], 30 + "panels": [ 31 + { 32 + "collapsed": false, 33 + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, 34 + "id": 100, 35 + "title": "Overview", 36 + "type": "row" 37 + }, 38 + { 39 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 40 + "fieldConfig": { 41 + "defaults": { 42 + "color": { "mode": "thresholds" }, 43 + "thresholds": { 44 + "steps": [ 45 + { "color": "green", "value": null } 46 + ] 47 + } 48 + }, 49 + "overrides": [] 50 + }, 51 + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, 52 + "id": 1, 53 + "options": { 54 + "colorMode": "background", 55 + "graphMode": "area", 56 + "justifyMode": "auto", 57 + "orientation": "auto", 58 + "reduceOptions": { "calcs": ["count"], "fields": "", "values": false }, 59 + "textMode": "auto" 60 + }, 61 + "title": "Total Requests", 62 + "type": "stat", 63 + "targets": [ 64 + { 65 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 66 + "expr": "count_over_time({${log_selector}} |= `HTTP request` [$__range])", 67 + "refId": "A" 68 + } 69 + ] 70 + }, 71 + { 72 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 73 + "fieldConfig": { 74 + "defaults": { 75 + "color": { "mode": "thresholds" }, 76 + "thresholds": { 77 + "steps": [ 78 + { "color": "green", "value": null }, 79 + { "color": "yellow", "value": 1 }, 80 + { "color": "red", "value": 10 } 81 + ] 82 + } 83 + }, 84 + "overrides": [] 85 + }, 86 + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, 87 + "id": 2, 88 + "options": { 89 + "colorMode": "background", 90 + "graphMode": "area", 91 + "justifyMode": "auto", 92 + "orientation": "auto", 93 + "reduceOptions": { "calcs": ["count"], "fields": "", "values": false }, 94 + "textMode": "auto" 95 + }, 96 + "title": "5xx Errors", 97 + "type": "stat", 98 + "targets": [ 99 + { 100 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 101 + "expr": "count_over_time({${log_selector}} |= `HTTP request` | json | status >= 500 [$__range])", 102 + "refId": "A" 103 + } 104 + ] 105 + }, 106 + { 107 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 108 + "fieldConfig": { 109 + "defaults": { 110 + "color": { "mode": "thresholds" }, 111 + "thresholds": { 112 + "steps": [ 113 + { "color": "green", "value": null }, 114 + { "color": "yellow", "value": 10 }, 115 + { "color": "orange", "value": 50 } 116 + ] 117 + } 118 + }, 119 + "overrides": [] 120 + }, 121 + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, 122 + "id": 3, 123 + "options": { 124 + "colorMode": "background", 125 + "graphMode": "area", 126 + "justifyMode": "auto", 127 + "orientation": "auto", 128 + "reduceOptions": { "calcs": ["count"], "fields": "", "values": false }, 129 + "textMode": "auto" 130 + }, 131 + "title": "4xx Errors", 132 + "type": "stat", 133 + "targets": [ 134 + { 135 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 136 + "expr": "count_over_time({${log_selector}} |= `HTTP request` | json | status >= 400 | status < 500 [$__range])", 137 + "refId": "A" 138 + } 139 + ] 140 + }, 141 + { 142 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 143 + "fieldConfig": { 144 + "defaults": { 145 + "color": { "mode": "thresholds" }, 146 + "thresholds": { 147 + "steps": [ 148 + { "color": "blue", "value": null } 149 + ] 150 + } 151 + }, 152 + "overrides": [] 153 + }, 154 + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, 155 + "id": 4, 156 + "options": { 157 + "colorMode": "background", 158 + "graphMode": "none", 159 + "justifyMode": "auto", 160 + "orientation": "auto", 161 + "reduceOptions": { "calcs": ["count"], "fields": "", "values": false }, 162 + "textMode": "auto" 163 + }, 164 + "title": "Unique Users", 165 + "type": "stat", 166 + "targets": [ 167 + { 168 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 169 + "expr": "count_over_time({${log_selector}} |= `User logged in successfully` [$__range])", 170 + "refId": "A" 171 + } 172 + ] 173 + }, 174 + { 175 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 176 + "fieldConfig": { 177 + "defaults": { 178 + "color": { "mode": "thresholds" }, 179 + "thresholds": { 180 + "steps": [ 181 + { "color": "purple", "value": null } 182 + ] 183 + } 184 + }, 185 + "overrides": [] 186 + }, 187 + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, 188 + "id": 5, 189 + "options": { 190 + "colorMode": "background", 191 + "graphMode": "area", 192 + "justifyMode": "auto", 193 + "orientation": "auto", 194 + "reduceOptions": { "calcs": ["count"], "fields": "", "values": false }, 195 + "textMode": "auto" 196 + }, 197 + "title": "Reports Filed", 198 + "type": "stat", 199 + "targets": [ 200 + { 201 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 202 + "expr": "count_over_time({${log_selector}} |= `Report created successfully` [$__range])", 203 + "refId": "A" 204 + } 205 + ] 206 + }, 207 + { 208 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 209 + "fieldConfig": { 210 + "defaults": { 211 + "color": { "mode": "thresholds" }, 212 + "thresholds": { 213 + "steps": [ 214 + { "color": "orange", "value": null } 215 + ] 216 + } 217 + }, 218 + "overrides": [] 219 + }, 220 + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, 221 + "id": 6, 222 + "options": { 223 + "colorMode": "background", 224 + "graphMode": "area", 225 + "justifyMode": "auto", 226 + "orientation": "auto", 227 + "reduceOptions": { "calcs": ["count"], "fields": "", "values": false }, 228 + "textMode": "auto" 229 + }, 230 + "title": "Join Requests", 231 + "type": "stat", 232 + "targets": [ 233 + { 234 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 235 + "expr": "count_over_time({${log_selector}} |= `Join request saved` [$__range])", 236 + "refId": "A" 237 + } 238 + ] 239 + }, 240 + { 241 + "collapsed": false, 242 + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, 243 + "id": 101, 244 + "title": "HTTP Traffic", 245 + "type": "row" 246 + }, 247 + { 248 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 249 + "fieldConfig": { 250 + "defaults": { 251 + "color": { "mode": "palette-classic" }, 252 + "custom": { 253 + "axisBorderShow": false, 254 + "drawStyle": "bars", 255 + "fillOpacity": 80, 256 + "stacking": { "mode": "normal" }, 257 + "lineWidth": 0, 258 + "pointSize": 5 259 + } 260 + }, 261 + "overrides": [] 262 + }, 263 + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, 264 + "id": 10, 265 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 266 + "title": "Requests by Status", 267 + "type": "timeseries", 268 + "targets": [ 269 + { 270 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 271 + "expr": "sum by (status) (count_over_time({${log_selector}} |= `HTTP request` | json | status >= 200 | status < 300 [$__auto]))", 272 + "legendFormat": "2xx", 273 + "refId": "A" 274 + }, 275 + { 276 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 277 + "expr": "sum by (status) (count_over_time({${log_selector}} |= `HTTP request` | json | status >= 300 | status < 400 [$__auto]))", 278 + "legendFormat": "3xx", 279 + "refId": "B" 280 + }, 281 + { 282 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 283 + "expr": "sum by (status) (count_over_time({${log_selector}} |= `HTTP request` | json | status >= 400 | status < 500 [$__auto]))", 284 + "legendFormat": "4xx", 285 + "refId": "C" 286 + }, 287 + { 288 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 289 + "expr": "sum by (status) (count_over_time({${log_selector}} |= `HTTP request` | json | status >= 500 [$__auto]))", 290 + "legendFormat": "5xx", 291 + "refId": "D" 292 + } 293 + ] 294 + }, 295 + { 296 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 297 + "fieldConfig": { 298 + "defaults": { 299 + "color": { "mode": "palette-classic" }, 300 + "custom": { 301 + "axisBorderShow": false, 302 + "drawStyle": "bars", 303 + "fillOpacity": 60, 304 + "stacking": { "mode": "normal" }, 305 + "lineWidth": 0, 306 + "pointSize": 5 307 + } 308 + }, 309 + "overrides": [] 310 + }, 311 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, 312 + "id": 11, 313 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 314 + "title": "Requests by Method", 315 + "type": "timeseries", 316 + "targets": [ 317 + { 318 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 319 + "expr": "sum by (method) (count_over_time({${log_selector}} |= `HTTP request` | json [$__auto]))", 320 + "legendFormat": "{{method}}", 321 + "refId": "A" 322 + } 323 + ] 324 + }, 325 + { 326 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 327 + "fieldConfig": { 328 + "defaults": { 329 + "color": { "mode": "palette-classic" }, 330 + "custom": { 331 + "axisBorderShow": false, 332 + "drawStyle": "bars", 333 + "fillOpacity": 60, 334 + "stacking": { "mode": "normal" }, 335 + "lineWidth": 0, 336 + "pointSize": 5 337 + } 338 + }, 339 + "overrides": [] 340 + }, 341 + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }, 342 + "id": 12, 343 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 344 + "title": "Top Paths", 345 + "type": "timeseries", 346 + "targets": [ 347 + { 348 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 349 + "expr": "sum by (path) (count_over_time({${log_selector}} |= `HTTP request` | json | path !~ `/static/.*` [$__auto])) ", 350 + "legendFormat": "{{path}}", 351 + "refId": "A" 352 + } 353 + ] 354 + }, 355 + { 356 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 357 + "fieldConfig": { 358 + "defaults": { 359 + "color": { "mode": "palette-classic" }, 360 + "custom": { 361 + "axisBorderShow": false, 362 + "drawStyle": "line", 363 + "fillOpacity": 20, 364 + "lineWidth": 2, 365 + "pointSize": 5 366 + }, 367 + "unit": "ms" 368 + }, 369 + "overrides": [] 370 + }, 371 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, 372 + "id": 13, 373 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 374 + "title": "Response Time (avg per interval)", 375 + "type": "timeseries", 376 + "targets": [ 377 + { 378 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 379 + "expr": "avg_over_time({${log_selector}} |= `HTTP request` | json | path !~ `/static/.*` | unwrap duration [$__auto]) / 1000000", 380 + "legendFormat": "avg latency", 381 + "refId": "A" 382 + } 383 + ] 384 + }, 385 + { 386 + "collapsed": false, 387 + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 22 }, 388 + "id": 102, 389 + "title": "Firehose", 390 + "type": "row" 391 + }, 392 + { 393 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 394 + "fieldConfig": { 395 + "defaults": { 396 + "color": { "mode": "palette-classic" }, 397 + "custom": { 398 + "axisBorderShow": false, 399 + "drawStyle": "bars", 400 + "fillOpacity": 60, 401 + "stacking": { "mode": "normal" }, 402 + "lineWidth": 0, 403 + "pointSize": 5 404 + } 405 + }, 406 + "overrides": [] 407 + }, 408 + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 23 }, 409 + "id": 20, 410 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 411 + "title": "Firehose Events by Collection", 412 + "type": "timeseries", 413 + "targets": [ 414 + { 415 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 416 + "expr": "sum by (collection) (count_over_time({${log_selector}} |= `firehose: processing event` | json [$__auto]))", 417 + "legendFormat": "{{collection}}", 418 + "refId": "A" 419 + } 420 + ] 421 + }, 422 + { 423 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 424 + "fieldConfig": { 425 + "defaults": { 426 + "color": { "mode": "palette-classic" }, 427 + "custom": { 428 + "axisBorderShow": false, 429 + "drawStyle": "bars", 430 + "fillOpacity": 60, 431 + "stacking": { "mode": "normal" }, 432 + "lineWidth": 0, 433 + "pointSize": 5 434 + } 435 + }, 436 + "overrides": [] 437 + }, 438 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 23 }, 439 + "id": 21, 440 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 441 + "title": "Firehose Events by Operation", 442 + "type": "timeseries", 443 + "targets": [ 444 + { 445 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 446 + "expr": "sum by (operation) (count_over_time({${log_selector}} |= `firehose: processing event` | json [$__auto]))", 447 + "legendFormat": "{{operation}}", 448 + "refId": "A" 449 + } 450 + ] 451 + }, 452 + { 453 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 454 + "fieldConfig": { 455 + "defaults": { 456 + "color": { "mode": "thresholds" }, 457 + "thresholds": { 458 + "steps": [ 459 + { "color": "green", "value": null }, 460 + { "color": "yellow", "value": 1 }, 461 + { "color": "red", "value": 5 } 462 + ] 463 + } 464 + }, 465 + "overrides": [] 466 + }, 467 + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 31 }, 468 + "id": 22, 469 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 470 + "title": "Firehose Errors", 471 + "type": "timeseries", 472 + "targets": [ 473 + { 474 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 475 + "expr": "count_over_time({${log_selector}} |= `firehose:` |~ `\"level\":\"(warn|error)\"` [$__auto])", 476 + "legendFormat": "errors", 477 + "refId": "A" 478 + } 479 + ] 480 + }, 481 + { 482 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 483 + "fieldConfig": { 484 + "defaults": { 485 + "color": { "mode": "palette-classic" }, 486 + "custom": { 487 + "axisBorderShow": false, 488 + "drawStyle": "bars", 489 + "fillOpacity": 60, 490 + "lineWidth": 0, 491 + "pointSize": 5 492 + } 493 + }, 494 + "overrides": [] 495 + }, 496 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 31 }, 497 + "id": 23, 498 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 499 + "title": "Backfills", 500 + "type": "timeseries", 501 + "targets": [ 502 + { 503 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 504 + "expr": "count_over_time({${log_selector}} |= `backfill complete` [$__auto])", 505 + "legendFormat": "completed", 506 + "refId": "A" 507 + }, 508 + { 509 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 510 + "expr": "count_over_time({${log_selector}} |= `backfilling user records` [$__auto])", 511 + "legendFormat": "started", 512 + "refId": "B" 513 + } 514 + ] 515 + }, 516 + { 517 + "collapsed": false, 518 + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 39 }, 519 + "id": 103, 520 + "title": "Authentication & Users", 521 + "type": "row" 522 + }, 523 + { 524 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 525 + "fieldConfig": { 526 + "defaults": { 527 + "color": { "mode": "palette-classic" }, 528 + "custom": { 529 + "axisBorderShow": false, 530 + "drawStyle": "bars", 531 + "fillOpacity": 60, 532 + "lineWidth": 0, 533 + "pointSize": 5 534 + } 535 + }, 536 + "overrides": [] 537 + }, 538 + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 40 }, 539 + "id": 30, 540 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 541 + "title": "Logins", 542 + "type": "timeseries", 543 + "targets": [ 544 + { 545 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 546 + "expr": "count_over_time({${log_selector}} |= `User logged in successfully` [$__auto])", 547 + "legendFormat": "successful logins", 548 + "refId": "A" 549 + }, 550 + { 551 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 552 + "expr": "count_over_time({${log_selector}} |= `Failed to initiate login` [$__auto])", 553 + "legendFormat": "failed logins", 554 + "refId": "B" 555 + }, 556 + { 557 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 558 + "expr": "count_over_time({${log_selector}} |= `Failed to complete OAuth flow` [$__auto])", 559 + "legendFormat": "failed OAuth callbacks", 560 + "refId": "C" 561 + } 562 + ] 563 + }, 564 + { 565 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 566 + "fieldConfig": { 567 + "defaults": { 568 + "color": { "mode": "palette-classic" }, 569 + "custom": { 570 + "axisBorderShow": false, 571 + "drawStyle": "bars", 572 + "fillOpacity": 60, 573 + "lineWidth": 0, 574 + "pointSize": 5 575 + } 576 + }, 577 + "overrides": [] 578 + }, 579 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 40 }, 580 + "id": 31, 581 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 582 + "title": "Join Requests & Invites", 583 + "type": "timeseries", 584 + "targets": [ 585 + { 586 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 587 + "expr": "count_over_time({${log_selector}} |= `Join request saved` [$__auto])", 588 + "legendFormat": "join requests", 589 + "refId": "A" 590 + }, 591 + { 592 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 593 + "expr": "count_over_time({${log_selector}} |= `Invite code created` [$__auto])", 594 + "legendFormat": "invites created", 595 + "refId": "B" 596 + }, 597 + { 598 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 599 + "expr": "count_over_time({${log_selector}} |= `Account created` [$__auto])", 600 + "legendFormat": "accounts created", 601 + "refId": "C" 602 + } 603 + ] 604 + }, 605 + { 606 + "collapsed": false, 607 + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 48 }, 608 + "id": 104, 609 + "title": "Moderation", 610 + "type": "row" 611 + }, 612 + { 613 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 614 + "fieldConfig": { 615 + "defaults": { 616 + "color": { "mode": "palette-classic" }, 617 + "custom": { 618 + "axisBorderShow": false, 619 + "drawStyle": "bars", 620 + "fillOpacity": 60, 621 + "lineWidth": 0, 622 + "pointSize": 5 623 + } 624 + }, 625 + "overrides": [] 626 + }, 627 + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 49 }, 628 + "id": 40, 629 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 630 + "title": "Moderation Actions", 631 + "type": "timeseries", 632 + "targets": [ 633 + { 634 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 635 + "expr": "count_over_time({${log_selector}} |= `Report created successfully` [$__auto])", 636 + "legendFormat": "reports", 637 + "refId": "A" 638 + }, 639 + { 640 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 641 + "expr": "count_over_time({${log_selector}} |= `Record hidden successfully` [$__auto])", 642 + "legendFormat": "records hidden", 643 + "refId": "B" 644 + }, 645 + { 646 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 647 + "expr": "count_over_time({${log_selector}} |= `Record unhidden successfully` [$__auto])", 648 + "legendFormat": "records unhidden", 649 + "refId": "C" 650 + }, 651 + { 652 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 653 + "expr": "count_over_time({${log_selector}} |= `User blocked successfully` [$__auto])", 654 + "legendFormat": "users blocked", 655 + "refId": "D" 656 + } 657 + ] 658 + }, 659 + { 660 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 661 + "fieldConfig": { 662 + "defaults": { 663 + "color": { "mode": "palette-classic" }, 664 + "custom": { 665 + "axisBorderShow": false, 666 + "drawStyle": "bars", 667 + "fillOpacity": 60, 668 + "lineWidth": 0, 669 + "pointSize": 5 670 + } 671 + }, 672 + "overrides": [] 673 + }, 674 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 49 }, 675 + "id": 41, 676 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 677 + "title": "Permission Denials", 678 + "type": "timeseries", 679 + "targets": [ 680 + { 681 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 682 + "expr": "count_over_time({${log_selector}} |= `Denied:` [$__auto])", 683 + "legendFormat": "denied", 684 + "refId": "A" 685 + } 686 + ] 687 + }, 688 + { 689 + "collapsed": false, 690 + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 57 }, 691 + "id": 105, 692 + "title": "PDS & ATProto", 693 + "type": "row" 694 + }, 695 + { 696 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 697 + "fieldConfig": { 698 + "defaults": { 699 + "color": { "mode": "palette-classic" }, 700 + "custom": { 701 + "axisBorderShow": false, 702 + "drawStyle": "bars", 703 + "fillOpacity": 60, 704 + "stacking": { "mode": "normal" }, 705 + "lineWidth": 0, 706 + "pointSize": 5 707 + } 708 + }, 709 + "overrides": [] 710 + }, 711 + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 58 }, 712 + "id": 50, 713 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 714 + "title": "PDS Requests by Method", 715 + "type": "timeseries", 716 + "targets": [ 717 + { 718 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 719 + "expr": "sum by (method) (count_over_time({${log_selector}} |= `PDS request completed` | json [$__auto]))", 720 + "legendFormat": "{{method}}", 721 + "refId": "A" 722 + } 723 + ] 724 + }, 725 + { 726 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 727 + "fieldConfig": { 728 + "defaults": { 729 + "color": { "mode": "palette-classic" }, 730 + "custom": { 731 + "axisBorderShow": false, 732 + "drawStyle": "line", 733 + "fillOpacity": 20, 734 + "lineWidth": 2, 735 + "pointSize": 5 736 + }, 737 + "unit": "ms" 738 + }, 739 + "overrides": [] 740 + }, 741 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 58 }, 742 + "id": 51, 743 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 744 + "title": "PDS Latency", 745 + "type": "timeseries", 746 + "targets": [ 747 + { 748 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 749 + "expr": "avg_over_time({${log_selector}} |= `PDS request completed` | json | unwrap duration [$__auto]) / 1000000", 750 + "legendFormat": "avg", 751 + "refId": "A" 752 + } 753 + ] 754 + }, 755 + { 756 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 757 + "fieldConfig": { 758 + "defaults": { 759 + "color": { "mode": "thresholds" }, 760 + "thresholds": { 761 + "steps": [ 762 + { "color": "green", "value": null }, 763 + { "color": "red", "value": 1 } 764 + ] 765 + } 766 + }, 767 + "overrides": [] 768 + }, 769 + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 66 }, 770 + "id": 52, 771 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 772 + "title": "PDS Errors", 773 + "type": "timeseries", 774 + "targets": [ 775 + { 776 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 777 + "expr": "count_over_time({${log_selector}} |= `PDS request failed` [$__auto])", 778 + "legendFormat": "PDS failures", 779 + "refId": "A" 780 + } 781 + ] 782 + }, 783 + { 784 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 785 + "fieldConfig": { 786 + "defaults": { 787 + "color": { "mode": "palette-classic" }, 788 + "custom": { 789 + "axisBorderShow": false, 790 + "drawStyle": "bars", 791 + "fillOpacity": 60, 792 + "stacking": { "mode": "normal" }, 793 + "lineWidth": 0, 794 + "pointSize": 5 795 + } 796 + }, 797 + "overrides": [] 798 + }, 799 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 66 }, 800 + "id": 53, 801 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 802 + "title": "PDS Requests by Collection", 803 + "type": "timeseries", 804 + "targets": [ 805 + { 806 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 807 + "expr": "sum by (collection) (count_over_time({${log_selector}} |= `PDS request completed` | json [$__auto]))", 808 + "legendFormat": "{{collection}}", 809 + "refId": "A" 810 + } 811 + ] 812 + }, 813 + { 814 + "collapsed": false, 815 + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 74 }, 816 + "id": 106, 817 + "title": "Errors & Warnings", 818 + "type": "row" 819 + }, 820 + { 821 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 822 + "fieldConfig": { 823 + "defaults": { 824 + "color": { "mode": "palette-classic" }, 825 + "custom": { 826 + "axisBorderShow": false, 827 + "drawStyle": "bars", 828 + "fillOpacity": 80, 829 + "stacking": { "mode": "normal" }, 830 + "lineWidth": 0, 831 + "pointSize": 5 832 + } 833 + }, 834 + "overrides": [ 835 + { 836 + "matcher": { "id": "byName", "options": "error" }, 837 + "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] 838 + }, 839 + { 840 + "matcher": { "id": "byName", "options": "warn" }, 841 + "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] 842 + } 843 + ] 844 + }, 845 + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 75 }, 846 + "id": 60, 847 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 848 + "title": "Errors & Warnings Over Time", 849 + "type": "timeseries", 850 + "targets": [ 851 + { 852 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 853 + "expr": "count_over_time({${log_selector}} |~ `\"level\":\"error\"` [$__auto])", 854 + "legendFormat": "error", 855 + "refId": "A" 856 + }, 857 + { 858 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 859 + "expr": "count_over_time({${log_selector}} |~ `\"level\":\"warn\"` [$__auto])", 860 + "legendFormat": "warn", 861 + "refId": "B" 862 + } 863 + ] 864 + }, 865 + { 866 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 867 + "gridPos": { "h": 12, "w": 24, "x": 0, "y": 83 }, 868 + "id": 61, 869 + "options": { 870 + "dedupStrategy": "none", 871 + "enableLogDetails": true, 872 + "prettifyLogMessage": true, 873 + "showCommonLabels": false, 874 + "showLabels": false, 875 + "showTime": true, 876 + "sortOrder": "Descending", 877 + "wrapLogMessage": true 878 + }, 879 + "title": "Recent Errors", 880 + "type": "logs", 881 + "targets": [ 882 + { 883 + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, 884 + "expr": "{${log_selector}} |~ `\"level\":\"error\"`", 885 + "refId": "A" 886 + } 887 + ] 888 + } 889 + ], 890 + "schemaVersion": 39, 891 + "tags": ["arabica", "loki", "logs"], 892 + "templating": { 893 + "list": [ 894 + { 895 + "current": { 896 + "selected": false, 897 + "text": "unit=\"arabica.service\"", 898 + "value": "unit=\"arabica.service\"" 899 + }, 900 + "description": "Loki stream selector for Arabica logs. Common values: unit=\"arabica.service\" (journald via Promtail), syslog_identifier=\"arabica\" (journald syslog), app=\"arabica\" (Docker/custom labels)", 901 + "hide": 0, 902 + "label": "Log Selector", 903 + "name": "log_selector", 904 + "options": [ 905 + { 906 + "selected": true, 907 + "text": "unit=\"arabica.service\"", 908 + "value": "unit=\"arabica.service\"" 909 + }, 910 + { 911 + "selected": false, 912 + "text": "syslog_identifier=\"arabica\"", 913 + "value": "syslog_identifier=\"arabica\"" 914 + }, 915 + { 916 + "selected": false, 917 + "text": "app=\"arabica\"", 918 + "value": "app=\"arabica\"" 919 + } 920 + ], 921 + "query": "unit=\"arabica.service\",syslog_identifier=\"arabica\",app=\"arabica\"", 922 + "skipUrlSync": false, 923 + "type": "custom" 924 + } 925 + ] 926 + }, 927 + "time": { "from": "now-24h", "to": "now" }, 928 + "timepicker": {}, 929 + "timezone": "", 930 + "title": "Arabica - Log Metrics", 931 + "uid": "arabica-logs", 932 + "version": 1 933 + }
+806
grafana/arabica-prometheus.json
··· 1 + { 2 + "__inputs": [ 3 + { 4 + "name": "DS_PROMETHEUS", 5 + "label": "Prometheus", 6 + "description": "Prometheus data source for Arabica metrics", 7 + "type": "datasource", 8 + "pluginId": "prometheus", 9 + "pluginName": "Prometheus" 10 + } 11 + ], 12 + "annotations": { 13 + "list": [ 14 + { 15 + "builtIn": 1, 16 + "datasource": { "type": "grafana", "uid": "-- Grafana --" }, 17 + "enable": true, 18 + "hide": true, 19 + "iconColor": "rgba(0, 211, 255, 1)", 20 + "name": "Annotations & Alerts", 21 + "type": "dashboard" 22 + } 23 + ] 24 + }, 25 + "editable": true, 26 + "fiscalYearStartMonth": 0, 27 + "graphTooltip": 1, 28 + "id": null, 29 + "links": [], 30 + "panels": [ 31 + { 32 + "collapsed": false, 33 + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, 34 + "id": 100, 35 + "title": "Overview", 36 + "type": "row" 37 + }, 38 + { 39 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 40 + "fieldConfig": { 41 + "defaults": { 42 + "color": { "mode": "thresholds" }, 43 + "thresholds": { "steps": [{ "color": "green", "value": null }] } 44 + } 45 + }, 46 + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, 47 + "id": 1, 48 + "options": { "colorMode": "background", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } }, 49 + "title": "Request Rate", 50 + "type": "stat", 51 + "targets": [ 52 + { 53 + "expr": "sum(rate(arabica_http_requests_total[5m]))", 54 + "legendFormat": "req/s", 55 + "refId": "A" 56 + } 57 + ] 58 + }, 59 + { 60 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 61 + "fieldConfig": { 62 + "defaults": { 63 + "color": { "mode": "thresholds" }, 64 + "thresholds": { "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 0.01 }] }, 65 + "unit": "percentunit" 66 + } 67 + }, 68 + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, 69 + "id": 2, 70 + "options": { "colorMode": "background", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } }, 71 + "title": "Error Rate (5xx)", 72 + "type": "stat", 73 + "targets": [ 74 + { 75 + "expr": "sum(rate(arabica_http_requests_total{status=~\"5..\"}[5m])) / sum(rate(arabica_http_requests_total[5m]))", 76 + "legendFormat": "", 77 + "refId": "A" 78 + } 79 + ] 80 + }, 81 + { 82 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 83 + "fieldConfig": { 84 + "defaults": { 85 + "color": { "mode": "thresholds" }, 86 + "thresholds": { "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 0.25 }, { "color": "red", "value": 1 }] }, 87 + "unit": "s" 88 + } 89 + }, 90 + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, 91 + "id": 3, 92 + "options": { "colorMode": "background", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } }, 93 + "title": "p95 Latency", 94 + "type": "stat", 95 + "targets": [ 96 + { 97 + "expr": "histogram_quantile(0.95, sum(rate(arabica_http_request_duration_seconds_bucket[5m])) by (le))", 98 + "legendFormat": "", 99 + "refId": "A" 100 + } 101 + ] 102 + }, 103 + { 104 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 105 + "fieldConfig": { 106 + "defaults": { 107 + "color": { "mode": "thresholds" }, 108 + "thresholds": { "steps": [{ "color": "blue", "value": null }] } 109 + } 110 + }, 111 + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, 112 + "id": 4, 113 + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }, 114 + "title": "Firehose Connected", 115 + "type": "stat", 116 + "targets": [ 117 + { 118 + "expr": "arabica_firehose_connection_state", 119 + "legendFormat": "", 120 + "refId": "A" 121 + } 122 + ] 123 + }, 124 + { 125 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 126 + "fieldConfig": { 127 + "defaults": { 128 + "color": { "mode": "thresholds" }, 129 + "thresholds": { "steps": [{ "color": "purple", "value": null }] } 130 + } 131 + }, 132 + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, 133 + "id": 5, 134 + "options": { "colorMode": "background", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } }, 135 + "title": "Firehose Events/s", 136 + "type": "stat", 137 + "targets": [ 138 + { 139 + "expr": "sum(rate(arabica_firehose_events_total[5m]))", 140 + "legendFormat": "", 141 + "refId": "A" 142 + } 143 + ] 144 + }, 145 + { 146 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 147 + "fieldConfig": { 148 + "defaults": { 149 + "color": { "mode": "thresholds" }, 150 + "thresholds": { "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 0.5 }] }, 151 + "unit": "percentunit" 152 + } 153 + }, 154 + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, 155 + "id": 6, 156 + "options": { "colorMode": "background", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } }, 157 + "title": "Feed Cache Hit Rate", 158 + "type": "stat", 159 + "targets": [ 160 + { 161 + "expr": "rate(arabica_feed_cache_hits_total[5m]) / (rate(arabica_feed_cache_hits_total[5m]) + rate(arabica_feed_cache_misses_total[5m]))", 162 + "legendFormat": "", 163 + "refId": "A" 164 + } 165 + ] 166 + }, 167 + { 168 + "collapsed": false, 169 + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, 170 + "id": 101, 171 + "title": "HTTP Traffic", 172 + "type": "row" 173 + }, 174 + { 175 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 176 + "fieldConfig": { 177 + "defaults": { 178 + "color": { "mode": "palette-classic" }, 179 + "custom": { "drawStyle": "line", "fillOpacity": 20, "lineWidth": 2, "stacking": { "mode": "normal" } } 180 + } 181 + }, 182 + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, 183 + "id": 10, 184 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 185 + "title": "Request Rate by Status", 186 + "type": "timeseries", 187 + "targets": [ 188 + { 189 + "expr": "sum by (status) (rate(arabica_http_requests_total{status=~\"2..\"}[5m]))", 190 + "legendFormat": "2xx", 191 + "refId": "A" 192 + }, 193 + { 194 + "expr": "sum by (status) (rate(arabica_http_requests_total{status=~\"3..\"}[5m]))", 195 + "legendFormat": "3xx", 196 + "refId": "B" 197 + }, 198 + { 199 + "expr": "sum by (status) (rate(arabica_http_requests_total{status=~\"4..\"}[5m]))", 200 + "legendFormat": "4xx", 201 + "refId": "C" 202 + }, 203 + { 204 + "expr": "sum by (status) (rate(arabica_http_requests_total{status=~\"5..\"}[5m]))", 205 + "legendFormat": "5xx", 206 + "refId": "D" 207 + } 208 + ] 209 + }, 210 + { 211 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 212 + "fieldConfig": { 213 + "defaults": { 214 + "color": { "mode": "palette-classic" }, 215 + "custom": { "drawStyle": "line", "fillOpacity": 20, "lineWidth": 2, "stacking": { "mode": "normal" } } 216 + } 217 + }, 218 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, 219 + "id": 11, 220 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 221 + "title": "Request Rate by Path", 222 + "type": "timeseries", 223 + "targets": [ 224 + { 225 + "expr": "sum by (path) (rate(arabica_http_requests_total{path!=\"/static/*\"}[5m])) > 0", 226 + "legendFormat": "{{path}}", 227 + "refId": "A" 228 + } 229 + ] 230 + }, 231 + { 232 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 233 + "fieldConfig": { 234 + "defaults": { 235 + "color": { "mode": "palette-classic" }, 236 + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2 }, 237 + "unit": "s" 238 + } 239 + }, 240 + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }, 241 + "id": 12, 242 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 243 + "title": "Request Latency Percentiles", 244 + "type": "timeseries", 245 + "targets": [ 246 + { 247 + "expr": "histogram_quantile(0.50, sum(rate(arabica_http_request_duration_seconds_bucket{path!=\"/static/*\"}[5m])) by (le))", 248 + "legendFormat": "p50", 249 + "refId": "A" 250 + }, 251 + { 252 + "expr": "histogram_quantile(0.95, sum(rate(arabica_http_request_duration_seconds_bucket{path!=\"/static/*\"}[5m])) by (le))", 253 + "legendFormat": "p95", 254 + "refId": "B" 255 + }, 256 + { 257 + "expr": "histogram_quantile(0.99, sum(rate(arabica_http_request_duration_seconds_bucket{path!=\"/static/*\"}[5m])) by (le))", 258 + "legendFormat": "p99", 259 + "refId": "C" 260 + } 261 + ] 262 + }, 263 + { 264 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 265 + "fieldConfig": { 266 + "defaults": { 267 + "color": { "mode": "palette-classic" }, 268 + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2 }, 269 + "unit": "s" 270 + } 271 + }, 272 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, 273 + "id": 13, 274 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 275 + "title": "Latency by Path (p95)", 276 + "type": "timeseries", 277 + "targets": [ 278 + { 279 + "expr": "histogram_quantile(0.95, sum by (le, path) (rate(arabica_http_request_duration_seconds_bucket{path!=\"/static/*\"}[5m])))", 280 + "legendFormat": "{{path}}", 281 + "refId": "A" 282 + } 283 + ] 284 + }, 285 + { 286 + "collapsed": false, 287 + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 22 }, 288 + "id": 102, 289 + "title": "Firehose", 290 + "type": "row" 291 + }, 292 + { 293 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 294 + "fieldConfig": { 295 + "defaults": { 296 + "color": { "mode": "palette-classic" }, 297 + "custom": { "drawStyle": "line", "fillOpacity": 20, "lineWidth": 2, "stacking": { "mode": "normal" } } 298 + } 299 + }, 300 + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 23 }, 301 + "id": 20, 302 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 303 + "title": "Events by Collection", 304 + "type": "timeseries", 305 + "targets": [ 306 + { 307 + "expr": "sum by (collection) (rate(arabica_firehose_events_total[5m]))", 308 + "legendFormat": "{{collection}}", 309 + "refId": "A" 310 + } 311 + ] 312 + }, 313 + { 314 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 315 + "fieldConfig": { 316 + "defaults": { 317 + "color": { "mode": "palette-classic" }, 318 + "custom": { "drawStyle": "line", "fillOpacity": 20, "lineWidth": 2, "stacking": { "mode": "normal" } } 319 + } 320 + }, 321 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 23 }, 322 + "id": 21, 323 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 324 + "title": "Events by Operation", 325 + "type": "timeseries", 326 + "targets": [ 327 + { 328 + "expr": "sum by (operation) (rate(arabica_firehose_events_total[5m]))", 329 + "legendFormat": "{{operation}}", 330 + "refId": "A" 331 + } 332 + ] 333 + }, 334 + { 335 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 336 + "fieldConfig": { 337 + "defaults": { 338 + "color": { "mode": "thresholds" }, 339 + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2 }, 340 + "thresholds": { "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 0.1 }] } 341 + } 342 + }, 343 + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 31 }, 344 + "id": 22, 345 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 346 + "title": "Firehose Error Rate", 347 + "type": "timeseries", 348 + "targets": [ 349 + { 350 + "expr": "rate(arabica_firehose_errors_total[5m])", 351 + "legendFormat": "errors/s", 352 + "refId": "A" 353 + } 354 + ] 355 + }, 356 + { 357 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 358 + "fieldConfig": { 359 + "defaults": { 360 + "color": { "mode": "thresholds" }, 361 + "mappings": [ 362 + { "options": { "0": { "color": "red", "text": "Disconnected" }, "1": { "color": "green", "text": "Connected" } }, "type": "value" } 363 + ], 364 + "thresholds": { "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } 365 + } 366 + }, 367 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 31 }, 368 + "id": 23, 369 + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }, 370 + "title": "Connection State", 371 + "type": "stat" , 372 + "targets": [ 373 + { 374 + "expr": "arabica_firehose_connection_state", 375 + "legendFormat": "", 376 + "refId": "A" 377 + } 378 + ] 379 + }, 380 + { 381 + "collapsed": false, 382 + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 39 }, 383 + "id": 103, 384 + "title": "PDS / ATProto", 385 + "type": "row" 386 + }, 387 + { 388 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 389 + "fieldConfig": { 390 + "defaults": { 391 + "color": { "mode": "palette-classic" }, 392 + "custom": { "drawStyle": "line", "fillOpacity": 20, "lineWidth": 2, "stacking": { "mode": "normal" } } 393 + } 394 + }, 395 + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 40 }, 396 + "id": 30, 397 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 398 + "title": "PDS Request Rate by Method", 399 + "type": "timeseries", 400 + "targets": [ 401 + { 402 + "expr": "sum by (method) (rate(arabica_pds_requests_total[5m]))", 403 + "legendFormat": "{{method}}", 404 + "refId": "A" 405 + } 406 + ] 407 + }, 408 + { 409 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 410 + "fieldConfig": { 411 + "defaults": { 412 + "color": { "mode": "palette-classic" }, 413 + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2 }, 414 + "unit": "s" 415 + } 416 + }, 417 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 40 }, 418 + "id": 31, 419 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 420 + "title": "PDS Latency by Method (p95)", 421 + "type": "timeseries", 422 + "targets": [ 423 + { 424 + "expr": "histogram_quantile(0.95, sum by (le, method) (rate(arabica_pds_request_duration_seconds_bucket[5m])))", 425 + "legendFormat": "{{method}}", 426 + "refId": "A" 427 + } 428 + ] 429 + }, 430 + { 431 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 432 + "fieldConfig": { 433 + "defaults": { 434 + "color": { "mode": "palette-classic" }, 435 + "custom": { "drawStyle": "line", "fillOpacity": 20, "lineWidth": 2, "stacking": { "mode": "normal" } } 436 + } 437 + }, 438 + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 48 }, 439 + "id": 32, 440 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 441 + "title": "PDS Requests by Collection", 442 + "type": "timeseries", 443 + "targets": [ 444 + { 445 + "expr": "sum by (collection) (rate(arabica_pds_requests_total[5m]))", 446 + "legendFormat": "{{collection}}", 447 + "refId": "A" 448 + } 449 + ] 450 + }, 451 + { 452 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 453 + "fieldConfig": { 454 + "defaults": { 455 + "color": { "mode": "thresholds" }, 456 + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2 }, 457 + "thresholds": { "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 0.1 }] } 458 + } 459 + }, 460 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 48 }, 461 + "id": 33, 462 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 463 + "title": "PDS Error Rate by Method", 464 + "type": "timeseries", 465 + "targets": [ 466 + { 467 + "expr": "sum by (method) (rate(arabica_pds_errors_total[5m]))", 468 + "legendFormat": "{{method}}", 469 + "refId": "A" 470 + } 471 + ] 472 + }, 473 + { 474 + "collapsed": false, 475 + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 56 }, 476 + "id": 104, 477 + "title": "Feed Cache", 478 + "type": "row" 479 + }, 480 + { 481 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 482 + "fieldConfig": { 483 + "defaults": { 484 + "color": { "mode": "palette-classic" }, 485 + "custom": { "drawStyle": "line", "fillOpacity": 20, "lineWidth": 2 } 486 + } 487 + }, 488 + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 57 }, 489 + "id": 40, 490 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 491 + "title": "Feed Cache Hits vs Misses", 492 + "type": "timeseries", 493 + "targets": [ 494 + { 495 + "expr": "rate(arabica_feed_cache_hits_total[5m])", 496 + "legendFormat": "hits", 497 + "refId": "A" 498 + }, 499 + { 500 + "expr": "rate(arabica_feed_cache_misses_total[5m])", 501 + "legendFormat": "misses", 502 + "refId": "B" 503 + } 504 + ] 505 + }, 506 + { 507 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 508 + "fieldConfig": { 509 + "defaults": { 510 + "color": { "mode": "thresholds" }, 511 + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2 }, 512 + "unit": "percentunit", 513 + "thresholds": { "steps": [{ "color": "red", "value": null }, { "color": "yellow", "value": 0.5 }, { "color": "green", "value": 0.8 }] } 514 + } 515 + }, 516 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 57 }, 517 + "id": 41, 518 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 519 + "title": "Feed Cache Hit Rate", 520 + "type": "timeseries", 521 + "targets": [ 522 + { 523 + "expr": "rate(arabica_feed_cache_hits_total[5m]) / (rate(arabica_feed_cache_hits_total[5m]) + rate(arabica_feed_cache_misses_total[5m]))", 524 + "legendFormat": "hit rate", 525 + "refId": "A" 526 + } 527 + ] 528 + }, 529 + { 530 + "collapsed": false, 531 + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 65 }, 532 + "id": 105, 533 + "title": "Users", 534 + "type": "row" 535 + }, 536 + { 537 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 538 + "fieldConfig": { 539 + "defaults": { 540 + "color": { "mode": "thresholds" }, 541 + "thresholds": { "steps": [{ "color": "blue", "value": null }] } 542 + } 543 + }, 544 + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 66 }, 545 + "id": 50, 546 + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }, 547 + "title": "Known Users", 548 + "type": "stat", 549 + "targets": [ 550 + { 551 + "expr": "arabica_known_users_total", 552 + "legendFormat": "", 553 + "refId": "A" 554 + } 555 + ] 556 + }, 557 + { 558 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 559 + "fieldConfig": { 560 + "defaults": { 561 + "color": { "mode": "thresholds" }, 562 + "thresholds": { "steps": [{ "color": "blue", "value": null }] } 563 + } 564 + }, 565 + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 66 }, 566 + "id": 51, 567 + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }, 568 + "title": "Registered Users", 569 + "type": "stat", 570 + "targets": [ 571 + { 572 + "expr": "arabica_registered_users_total", 573 + "legendFormat": "", 574 + "refId": "A" 575 + } 576 + ] 577 + }, 578 + { 579 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 580 + "fieldConfig": { 581 + "defaults": { 582 + "color": { "mode": "palette-classic" }, 583 + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2 } 584 + } 585 + }, 586 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 66 }, 587 + "id": 52, 588 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 589 + "title": "Login Rate", 590 + "type": "timeseries", 591 + "targets": [ 592 + { 593 + "expr": "rate(arabica_auth_logins_total{status=\"success\"}[5m])", 594 + "legendFormat": "success", 595 + "refId": "A" 596 + }, 597 + { 598 + "expr": "rate(arabica_auth_logins_total{status=\"failure\"}[5m])", 599 + "legendFormat": "failure", 600 + "refId": "B" 601 + } 602 + ] 603 + }, 604 + { 605 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 606 + "fieldConfig": { 607 + "defaults": { 608 + "color": { "mode": "thresholds" }, 609 + "thresholds": { "steps": [{ "color": "blue", "value": null }] } 610 + } 611 + }, 612 + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 70 }, 613 + "id": 53, 614 + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }, 615 + "title": "Indexed Records", 616 + "type": "stat", 617 + "targets": [ 618 + { 619 + "expr": "arabica_indexed_records_total", 620 + "legendFormat": "", 621 + "refId": "A" 622 + } 623 + ] 624 + }, 625 + { 626 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 627 + "fieldConfig": { 628 + "defaults": { 629 + "color": { "mode": "palette-classic" }, 630 + "custom": { "drawStyle": "bars", "fillOpacity": 80, "lineWidth": 1 } 631 + } 632 + }, 633 + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 70 }, 634 + "id": 54, 635 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 636 + "title": "Records by Collection", 637 + "type": "timeseries", 638 + "targets": [ 639 + { 640 + "expr": "arabica_indexed_records_by_collection", 641 + "legendFormat": "{{collection}}", 642 + "refId": "A" 643 + } 644 + ] 645 + }, 646 + { 647 + "collapsed": false, 648 + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 74 }, 649 + "id": 106, 650 + "title": "Engagement", 651 + "type": "row" 652 + }, 653 + { 654 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 655 + "fieldConfig": { 656 + "defaults": { 657 + "color": { "mode": "thresholds" }, 658 + "thresholds": { "steps": [{ "color": "purple", "value": null }] } 659 + } 660 + }, 661 + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 75 }, 662 + "id": 60, 663 + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }, 664 + "title": "Total Likes", 665 + "type": "stat", 666 + "targets": [ 667 + { 668 + "expr": "arabica_indexed_likes_total", 669 + "legendFormat": "", 670 + "refId": "A" 671 + } 672 + ] 673 + }, 674 + { 675 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 676 + "fieldConfig": { 677 + "defaults": { 678 + "color": { "mode": "thresholds" }, 679 + "thresholds": { "steps": [{ "color": "purple", "value": null }] } 680 + } 681 + }, 682 + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 75 }, 683 + "id": 61, 684 + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }, 685 + "title": "Total Comments", 686 + "type": "stat", 687 + "targets": [ 688 + { 689 + "expr": "arabica_indexed_comments_total", 690 + "legendFormat": "", 691 + "refId": "A" 692 + } 693 + ] 694 + }, 695 + { 696 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 697 + "fieldConfig": { 698 + "defaults": { 699 + "color": { "mode": "palette-classic" }, 700 + "custom": { "drawStyle": "line", "fillOpacity": 20, "lineWidth": 2 } 701 + } 702 + }, 703 + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 75 }, 704 + "id": 62, 705 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 706 + "title": "Like & Comment Rate", 707 + "type": "timeseries", 708 + "targets": [ 709 + { 710 + "expr": "sum by (operation) (rate(arabica_likes_total[5m]))", 711 + "legendFormat": "likes ({{operation}})", 712 + "refId": "A" 713 + }, 714 + { 715 + "expr": "sum by (operation) (rate(arabica_comments_total[5m]))", 716 + "legendFormat": "comments ({{operation}})", 717 + "refId": "B" 718 + } 719 + ] 720 + }, 721 + { 722 + "collapsed": false, 723 + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 83 }, 724 + "id": 107, 725 + "title": "Community", 726 + "type": "row" 727 + }, 728 + { 729 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 730 + "fieldConfig": { 731 + "defaults": { 732 + "color": { "mode": "thresholds" }, 733 + "thresholds": { "steps": [{ "color": "orange", "value": null }] } 734 + } 735 + }, 736 + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 84 }, 737 + "id": 70, 738 + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }, 739 + "title": "Pending Join Requests", 740 + "type": "stat", 741 + "targets": [ 742 + { 743 + "expr": "arabica_join_requests_pending", 744 + "legendFormat": "", 745 + "refId": "A" 746 + } 747 + ] 748 + }, 749 + { 750 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 751 + "fieldConfig": { 752 + "defaults": { 753 + "color": { "mode": "palette-classic" }, 754 + "custom": { "drawStyle": "line", "fillOpacity": 20, "lineWidth": 2 } 755 + } 756 + }, 757 + "gridPos": { "h": 8, "w": 12, "x": 6, "y": 84 }, 758 + "id": 71, 759 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 760 + "title": "Join Requests & Invites Rate", 761 + "type": "timeseries", 762 + "targets": [ 763 + { 764 + "expr": "rate(arabica_join_requests_total[5m])", 765 + "legendFormat": "join requests", 766 + "refId": "A" 767 + }, 768 + { 769 + "expr": "rate(arabica_invites_created_total[5m])", 770 + "legendFormat": "invites sent", 771 + "refId": "B" 772 + } 773 + ] 774 + }, 775 + { 776 + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, 777 + "fieldConfig": { 778 + "defaults": { 779 + "color": { "mode": "palette-classic" }, 780 + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2 } 781 + } 782 + }, 783 + "gridPos": { "h": 8, "w": 6, "x": 18, "y": 84 }, 784 + "id": 72, 785 + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, 786 + "title": "Reports Rate", 787 + "type": "timeseries", 788 + "targets": [ 789 + { 790 + "expr": "rate(arabica_reports_total[5m])", 791 + "legendFormat": "reports/s", 792 + "refId": "A" 793 + } 794 + ] 795 + } 796 + ], 797 + "schemaVersion": 39, 798 + "tags": ["arabica", "prometheus", "metrics"], 799 + "templating": { "list": [] }, 800 + "time": { "from": "now-1h", "to": "now" }, 801 + "timepicker": {}, 802 + "timezone": "", 803 + "title": "Arabica - Prometheus Metrics", 804 + "uid": "arabica-prometheus", 805 + "version": 1 806 + }
+17
internal/atproto/client.go
··· 5 5 "fmt" 6 6 "time" 7 7 8 + "arabica/internal/metrics" 9 + 8 10 "github.com/bluesky-social/indigo/atproto/atclient" 9 11 "github.com/bluesky-social/indigo/atproto/syntax" 10 12 "github.com/rs/zerolog/log" ··· 80 82 err = apiClient.Post(ctx, "com.atproto.repo.createRecord", body, &result) 81 83 82 84 duration := time.Since(start) 85 + metrics.PDSRequestDuration.WithLabelValues("createRecord").Observe(duration.Seconds()) 86 + metrics.PDSRequestsTotal.WithLabelValues("createRecord", input.Collection).Inc() 83 87 84 88 if err != nil { 89 + metrics.PDSErrorsTotal.WithLabelValues("createRecord").Inc() 85 90 log.Error(). 86 91 Err(err). 87 92 Str("method", "createRecord"). ··· 146 151 err = apiClient.Get(ctx, "com.atproto.repo.getRecord", params, &result) 147 152 148 153 duration := time.Since(start) 154 + metrics.PDSRequestDuration.WithLabelValues("getRecord").Observe(duration.Seconds()) 155 + metrics.PDSRequestsTotal.WithLabelValues("getRecord", input.Collection).Inc() 149 156 150 157 if err != nil { 158 + metrics.PDSErrorsTotal.WithLabelValues("getRecord").Inc() 151 159 log.Error(). 152 160 Err(err). 153 161 Str("method", "getRecord"). ··· 232 240 233 241 duration := time.Since(start) 234 242 recordCount := len(result.Records) 243 + metrics.PDSRequestDuration.WithLabelValues("listRecords").Observe(duration.Seconds()) 244 + metrics.PDSRequestsTotal.WithLabelValues("listRecords", input.Collection).Inc() 235 245 236 246 if err != nil { 247 + metrics.PDSErrorsTotal.WithLabelValues("listRecords").Inc() 237 248 log.Error(). 238 249 Err(err). 239 250 Str("method", "listRecords"). ··· 366 377 err = apiClient.Post(ctx, "com.atproto.repo.putRecord", body, &result) 367 378 368 379 duration := time.Since(start) 380 + metrics.PDSRequestDuration.WithLabelValues("putRecord").Observe(duration.Seconds()) 381 + metrics.PDSRequestsTotal.WithLabelValues("putRecord", input.Collection).Inc() 369 382 370 383 if err != nil { 384 + metrics.PDSErrorsTotal.WithLabelValues("putRecord").Inc() 371 385 log.Error(). 372 386 Err(err). 373 387 Str("method", "putRecord"). ··· 420 434 err = apiClient.Post(ctx, "com.atproto.repo.deleteRecord", body, &result) 421 435 422 436 duration := time.Since(start) 437 + metrics.PDSRequestDuration.WithLabelValues("deleteRecord").Observe(duration.Seconds()) 438 + metrics.PDSRequestsTotal.WithLabelValues("deleteRecord", input.Collection).Inc() 423 439 424 440 if err != nil { 441 + metrics.PDSErrorsTotal.WithLabelValues("deleteRecord").Inc() 425 442 log.Error(). 426 443 Err(err). 427 444 Str("method", "deleteRecord").
+3
internal/feed/service.go
··· 8 8 9 9 "arabica/internal/atproto" 10 10 "arabica/internal/lexicons" 11 + "arabica/internal/metrics" 11 12 "arabica/internal/models" 12 13 13 14 "github.com/rs/zerolog/log" ··· 216 217 s.cache.mu.RUnlock() 217 218 218 219 if cacheValid { 220 + metrics.FeedCacheHitsTotal.Inc() 219 221 // Apply moderation filtering to cached items 220 222 // This ensures recently hidden content doesn't appear 221 223 items = s.filterModeratedItems(ctx, items) ··· 247 249 return items, nil 248 250 } 249 251 252 + metrics.FeedCacheMissesTotal.Inc() 250 253 log.Debug().Msg("feed: refreshing public feed cache") 251 254 252 255 // Fetch PublicFeedCacheSize items to cache (20 items)
+7
internal/firehose/consumer.go
··· 10 10 "sync/atomic" 11 11 "time" 12 12 13 + "arabica/internal/metrics" 14 + 13 15 "github.com/gorilla/websocket" 14 16 "github.com/klauspost/compress/zstd" 15 17 "github.com/rs/zerolog/log" ··· 185 187 c.connMu.Unlock() 186 188 187 189 c.connected.Store(true) 190 + metrics.FirehoseConnectionState.Set(1) 188 191 log.Info().Str("endpoint", endpoint).Msg("firehose: connected to Jetstream") 189 192 190 193 // Mark index as ready once connected ··· 198 201 } 199 202 c.connMu.Unlock() 200 203 c.connected.Store(false) 204 + metrics.FirehoseConnectionState.Set(0) 201 205 }() 202 206 203 207 // Read events ··· 221 225 c.bytesReceived.Add(int64(len(message))) 222 226 223 227 if err := c.processMessage(message); err != nil { 228 + metrics.FirehoseErrorsTotal.Inc() 224 229 log.Warn().Err(err).Msg("firehose: failed to process message") 225 230 } 226 231 } ··· 309 314 if !strings.HasPrefix(commit.Collection, "social.arabica.alpha.") { 310 315 return nil 311 316 } 317 + 318 + metrics.FirehoseEventsTotal.WithLabelValues(commit.Collection, commit.Operation).Inc() 312 319 313 320 log.Debug(). 314 321 Str("did", event.DID).
+50
internal/firehose/index.go
··· 959 959 return count 960 960 } 961 961 962 + // KnownDIDCount returns the number of unique DIDs in the index 963 + func (idx *FeedIndex) KnownDIDCount() int { 964 + var count int 965 + _ = idx.db.View(func(tx *bolt.Tx) error { 966 + b := tx.Bucket(BucketKnownDIDs) 967 + count = b.Stats().KeyN 968 + return nil 969 + }) 970 + return count 971 + } 972 + 973 + // TotalLikeCount returns the total number of likes indexed 974 + func (idx *FeedIndex) TotalLikeCount() int { 975 + var count int 976 + _ = idx.db.View(func(tx *bolt.Tx) error { 977 + b := tx.Bucket(BucketLikes) 978 + count = b.Stats().KeyN 979 + return nil 980 + }) 981 + return count 982 + } 983 + 984 + // TotalCommentCount returns the total number of comments indexed 985 + func (idx *FeedIndex) TotalCommentCount() int { 986 + var count int 987 + _ = idx.db.View(func(tx *bolt.Tx) error { 988 + b := tx.Bucket(BucketCommentsByActor) 989 + count = b.Stats().KeyN 990 + return nil 991 + }) 992 + return count 993 + } 994 + 995 + // RecordCountByCollection returns a breakdown of record counts by collection type 996 + func (idx *FeedIndex) RecordCountByCollection() map[string]int { 997 + counts := make(map[string]int) 998 + _ = idx.db.View(func(tx *bolt.Tx) error { 999 + records := tx.Bucket(BucketRecords) 1000 + return records.ForEach(func(k, v []byte) error { 1001 + var record IndexedRecord 1002 + if err := json.Unmarshal(v, &record); err != nil { 1003 + return nil 1004 + } 1005 + counts[record.Collection]++ 1006 + return nil 1007 + }) 1008 + }) 1009 + return counts 1010 + } 1011 + 962 1012 // Helper functions 963 1013 964 1014 func makeTimeKey(t time.Time, uri string) []byte {
+71
internal/handlers/admin.go
··· 7 7 8 8 "arabica/internal/atproto" 9 9 "arabica/internal/database/boltstore" 10 + "arabica/internal/metrics" 10 11 "arabica/internal/middleware" 11 12 "arabica/internal/moderation" 12 13 "arabica/internal/web/components" 13 14 "arabica/internal/web/pages" 14 15 15 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 + "github.com/prometheus/client_golang/prometheus" 18 + dto "github.com/prometheus/client_model/go" 16 19 "github.com/rs/zerolog/log" 17 20 ) 18 21 ··· 195 198 joinRequests, _ = h.joinStore.ListRequests() 196 199 } 197 200 201 + // Build stats for admin users 202 + var stats pages.AdminStats 203 + if isAdmin { 204 + stats = h.collectAdminStats() 205 + } 206 + 198 207 return pages.AdminProps{ 199 208 HiddenRecords: hiddenRecords, 200 209 AuditLog: auditLog, 201 210 Reports: enrichedReports, 202 211 BlockedUsers: blockedUsers, 203 212 JoinRequests: joinRequests, 213 + Stats: stats, 204 214 CanHide: canHide, 205 215 CanUnhide: canUnhide, 206 216 CanViewLogs: canViewLogs, ··· 589 599 w.Header().Set("HX-Trigger", "mod-action") 590 600 w.WriteHeader(http.StatusOK) 591 601 } 602 + 603 + // collectAdminStats gathers current system statistics from available data sources. 604 + func (h *Handler) collectAdminStats() pages.AdminStats { 605 + var stats pages.AdminStats 606 + 607 + if h.feedIndex != nil { 608 + stats.KnownUsers = h.feedIndex.KnownDIDCount() 609 + stats.IndexedRecords = h.feedIndex.RecordCount() 610 + stats.TotalLikes = h.feedIndex.TotalLikeCount() 611 + stats.TotalComments = h.feedIndex.TotalCommentCount() 612 + stats.RecordsByCollection = h.feedIndex.RecordCountByCollection() 613 + } 614 + 615 + if h.feedRegistry != nil { 616 + stats.RegisteredUsers = h.feedRegistry.Count() 617 + } 618 + 619 + if h.joinStore != nil { 620 + if reqs, err := h.joinStore.ListRequests(); err == nil { 621 + stats.PendingJoinRequests = len(reqs) 622 + } 623 + } 624 + 625 + // Read firehose connection state from the Prometheus gauge 626 + stats.FirehoseConnected = getGaugeValue(metrics.FirehoseConnectionState) == 1 627 + 628 + return stats 629 + } 630 + 631 + // getGaugeValue reads the current value of a prometheus.Gauge. 632 + func getGaugeValue(g prometheus.Gauge) float64 { 633 + m := &dto.Metric{} 634 + if err := g.Write(m); err != nil { 635 + return 0 636 + } 637 + if m.Gauge != nil { 638 + return m.GetGauge().GetValue() 639 + } 640 + return 0 641 + } 642 + 643 + // HandleAdminStats renders the stats partial for HTMX refresh. 644 + func (h *Handler) HandleAdminStats(w http.ResponseWriter, r *http.Request) { 645 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 646 + if err != nil || userDID == "" { 647 + http.Error(w, "Authentication required", http.StatusUnauthorized) 648 + return 649 + } 650 + 651 + if h.moderationService == nil || !h.moderationService.IsAdmin(userDID) { 652 + http.Error(w, "Access denied", http.StatusForbidden) 653 + return 654 + } 655 + 656 + stats := h.collectAdminStats() 657 + 658 + if err := pages.AdminStatsContent(stats).Render(r.Context(), w); err != nil { 659 + log.Error().Err(err).Msg("Failed to render admin stats partial") 660 + http.Error(w, "Failed to render", http.StatusInternalServerError) 661 + } 662 + }
+5
internal/handlers/auth.go
··· 7 7 "net/http" 8 8 "time" 9 9 10 + "arabica/internal/metrics" 11 + 10 12 "github.com/bluesky-social/indigo/atproto/syntax" 11 13 "github.com/rs/zerolog/log" 12 14 ) ··· 65 67 // Process the callback with all query parameters 66 68 sessData, err := h.oauth.HandleCallback(r.Context(), r.URL.Query()) 67 69 if err != nil { 70 + metrics.AuthLoginsTotal.WithLabelValues("failure").Inc() 68 71 log.Error().Err(err).Msg("Failed to complete OAuth flow") 69 72 http.Error(w, "Failed to complete login", http.StatusInternalServerError) 70 73 return ··· 95 98 SameSite: http.SameSiteLaxMode, 96 99 MaxAge: 86400 * 30, // 30 days 97 100 }) 101 + 102 + metrics.AuthLoginsTotal.WithLabelValues("success").Inc() 98 103 99 104 log.Info(). 100 105 Str("user_did", sessData.AccountDID.String()).
+3
internal/handlers/feed.go
··· 7 7 "arabica/internal/atproto" 8 8 "arabica/internal/feed" 9 9 "arabica/internal/lexicons" 10 + "arabica/internal/metrics" 10 11 "arabica/internal/models" 11 12 "arabica/internal/moderation" 12 13 "arabica/internal/web/components" ··· 185 186 return 186 187 } 187 188 isLiked = false 189 + metrics.LikesTotal.WithLabelValues("delete").Inc() 188 190 189 191 // Update firehose index 190 192 if h.feedIndex != nil { ··· 204 206 return 205 207 } 206 208 isLiked = true 209 + metrics.LikesTotal.WithLabelValues("create").Inc() 207 210 208 211 // Update firehose index 209 212 if h.feedIndex != nil {
+5
internal/handlers/handlers.go
··· 13 13 "arabica/internal/email" 14 14 "arabica/internal/feed" 15 15 "arabica/internal/firehose" 16 + "arabica/internal/metrics" 16 17 "arabica/internal/middleware" 17 18 "arabica/internal/models" 18 19 "arabica/internal/moderation" ··· 315 316 return 316 317 } 317 318 319 + metrics.CommentsTotal.WithLabelValues("create").Inc() 320 + 318 321 // Update firehose index (pass parent URI and comment's CID for threading) 319 322 if h.feedIndex != nil { 320 323 _ = h.feedIndex.UpsertComment(didStr, comment.RKey, subjectURI, parentURI, comment.CID, text, comment.CreatedAt) ··· 390 393 log.Error().Err(err).Msg("Failed to delete comment") 391 394 return 392 395 } 396 + 397 + metrics.CommentsTotal.WithLabelValues("delete").Inc() 393 398 394 399 // Update firehose index 395 400 if h.feedIndex != nil {
+3
internal/handlers/join.go
··· 9 9 10 10 "arabica/internal/atproto" 11 11 "arabica/internal/database/boltstore" 12 + "arabica/internal/metrics" 12 13 "arabica/internal/middleware" 13 14 "arabica/internal/moderation" 14 15 "arabica/internal/web/pages" ··· 66 67 http.Error(w, "Failed to save request, please try again", http.StatusInternalServerError) 67 68 return 68 69 } 70 + metrics.JoinRequestsTotal.Inc() 69 71 log.Info().Str("email", emailAddr).Msg("Join request saved") 70 72 } 71 73 ··· 146 148 return 147 149 } 148 150 151 + metrics.InvitesCreatedTotal.Inc() 149 152 log.Info().Str("email", reqEmail).Str("code", out.Code).Str("by", userDID).Msg("Invite code created") 150 153 151 154 // Email the invite code to the requester
+3
internal/handlers/report.go
··· 9 9 "time" 10 10 11 11 "arabica/internal/atproto" 12 + "arabica/internal/metrics" 12 13 "arabica/internal/moderation" 13 14 14 15 "github.com/rs/zerolog/log" ··· 141 142 writeReportError(w, "Failed to save report", http.StatusInternalServerError) 142 143 return 143 144 } 145 + 146 + metrics.ReportsTotal.Inc() 144 147 145 148 log.Info(). 146 149 Str("report_id", report.ID).
+77
internal/metrics/collector.go
··· 1 + package metrics 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "github.com/rs/zerolog/log" 8 + ) 9 + 10 + // StatsSource provides functions to retrieve current counts for gauge metrics. 11 + // Each function returns the current count; returning -1 indicates the source is unavailable. 12 + type StatsSource struct { 13 + KnownDIDCount func() int 14 + RegisteredCount func() int 15 + RecordCount func() int 16 + PendingJoinCount func() int 17 + LikeCount func() int 18 + CommentCount func() int 19 + RecordCountByCollection func() map[string]int 20 + FirehoseConnected func() bool 21 + } 22 + 23 + // StartCollector launches a goroutine that periodically updates gauge metrics. 24 + // It runs every interval until the context is cancelled. 25 + func StartCollector(ctx context.Context, src StatsSource, interval time.Duration) { 26 + // Do an initial collection immediately 27 + collect(src) 28 + 29 + go func() { 30 + ticker := time.NewTicker(interval) 31 + defer ticker.Stop() 32 + 33 + for { 34 + select { 35 + case <-ctx.Done(): 36 + return 37 + case <-ticker.C: 38 + collect(src) 39 + } 40 + } 41 + }() 42 + 43 + log.Info().Dur("interval", interval).Msg("Metrics collector started") 44 + } 45 + 46 + func collect(src StatsSource) { 47 + if src.KnownDIDCount != nil { 48 + KnownUsersTotal.Set(float64(src.KnownDIDCount())) 49 + } 50 + if src.RegisteredCount != nil { 51 + RegisteredUsersTotal.Set(float64(src.RegisteredCount())) 52 + } 53 + if src.RecordCount != nil { 54 + IndexedRecordsTotal.Set(float64(src.RecordCount())) 55 + } 56 + if src.PendingJoinCount != nil { 57 + JoinRequestsPending.Set(float64(src.PendingJoinCount())) 58 + } 59 + if src.LikeCount != nil { 60 + IndexedLikesTotal.Set(float64(src.LikeCount())) 61 + } 62 + if src.CommentCount != nil { 63 + IndexedCommentsTotal.Set(float64(src.CommentCount())) 64 + } 65 + if src.RecordCountByCollection != nil { 66 + for collection, count := range src.RecordCountByCollection() { 67 + IndexedRecordsByCollection.WithLabelValues(collection).Set(float64(count)) 68 + } 69 + } 70 + if src.FirehoseConnected != nil { 71 + if src.FirehoseConnected() { 72 + FirehoseConnectionState.Set(1) 73 + } else { 74 + FirehoseConnectionState.Set(0) 75 + } 76 + } 77 + }
+222
internal/metrics/metrics.go
··· 1 + package metrics 2 + 3 + import ( 4 + "github.com/prometheus/client_golang/prometheus" 5 + "github.com/prometheus/client_golang/prometheus/promauto" 6 + ) 7 + 8 + // HTTP metrics 9 + var ( 10 + HTTPRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ 11 + Name: "arabica_http_requests_total", 12 + Help: "Total number of HTTP requests", 13 + }, []string{"method", "path", "status"}) 14 + 15 + HTTPRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ 16 + Name: "arabica_http_request_duration_seconds", 17 + Help: "HTTP request duration in seconds", 18 + Buckets: []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5}, 19 + }, []string{"method", "path"}) 20 + ) 21 + 22 + // Firehose metrics 23 + var ( 24 + FirehoseEventsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ 25 + Name: "arabica_firehose_events_total", 26 + Help: "Total number of firehose events processed", 27 + }, []string{"collection", "operation"}) 28 + 29 + FirehoseConnectionState = promauto.NewGauge(prometheus.GaugeOpts{ 30 + Name: "arabica_firehose_connection_state", 31 + Help: "Firehose connection state (1=connected, 0=disconnected)", 32 + }) 33 + 34 + FirehoseErrorsTotal = promauto.NewCounter(prometheus.CounterOpts{ 35 + Name: "arabica_firehose_errors_total", 36 + Help: "Total number of firehose processing errors", 37 + }) 38 + ) 39 + 40 + // PDS metrics 41 + var ( 42 + PDSRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ 43 + Name: "arabica_pds_requests_total", 44 + Help: "Total number of PDS requests", 45 + }, []string{"method", "collection"}) 46 + 47 + PDSRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ 48 + Name: "arabica_pds_request_duration_seconds", 49 + Help: "PDS request duration in seconds", 50 + Buckets: []float64{0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}, 51 + }, []string{"method"}) 52 + 53 + PDSErrorsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ 54 + Name: "arabica_pds_errors_total", 55 + Help: "Total number of PDS request errors", 56 + }, []string{"method"}) 57 + ) 58 + 59 + // Feed metrics 60 + var ( 61 + FeedCacheHitsTotal = promauto.NewCounter(prometheus.CounterOpts{ 62 + Name: "arabica_feed_cache_hits_total", 63 + Help: "Total number of feed cache hits", 64 + }) 65 + 66 + FeedCacheMissesTotal = promauto.NewCounter(prometheus.CounterOpts{ 67 + Name: "arabica_feed_cache_misses_total", 68 + Help: "Total number of feed cache misses", 69 + }) 70 + ) 71 + 72 + // Business metrics (gauges updated periodically by collector) 73 + var ( 74 + KnownUsersTotal = promauto.NewGauge(prometheus.GaugeOpts{ 75 + Name: "arabica_known_users_total", 76 + Help: "Total number of unique DIDs in the index", 77 + }) 78 + 79 + RegisteredUsersTotal = promauto.NewGauge(prometheus.GaugeOpts{ 80 + Name: "arabica_registered_users_total", 81 + Help: "Total number of registered feed users", 82 + }) 83 + 84 + IndexedRecordsTotal = promauto.NewGauge(prometheus.GaugeOpts{ 85 + Name: "arabica_indexed_records_total", 86 + Help: "Total number of indexed records", 87 + }) 88 + 89 + JoinRequestsPending = promauto.NewGauge(prometheus.GaugeOpts{ 90 + Name: "arabica_join_requests_pending", 91 + Help: "Number of pending join requests", 92 + }) 93 + 94 + IndexedLikesTotal = promauto.NewGauge(prometheus.GaugeOpts{ 95 + Name: "arabica_indexed_likes_total", 96 + Help: "Total number of indexed likes", 97 + }) 98 + 99 + IndexedCommentsTotal = promauto.NewGauge(prometheus.GaugeOpts{ 100 + Name: "arabica_indexed_comments_total", 101 + Help: "Total number of indexed comments", 102 + }) 103 + 104 + IndexedRecordsByCollection = promauto.NewGaugeVec(prometheus.GaugeOpts{ 105 + Name: "arabica_indexed_records_by_collection", 106 + Help: "Number of indexed records by collection type", 107 + }, []string{"collection"}) 108 + ) 109 + 110 + // Event counters (incremented on occurrence) 111 + var ( 112 + AuthLoginsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ 113 + Name: "arabica_auth_logins_total", 114 + Help: "Total number of login attempts", 115 + }, []string{"status"}) 116 + 117 + JoinRequestsTotal = promauto.NewCounter(prometheus.CounterOpts{ 118 + Name: "arabica_join_requests_total", 119 + Help: "Total number of join request submissions", 120 + }) 121 + 122 + InvitesCreatedTotal = promauto.NewCounter(prometheus.CounterOpts{ 123 + Name: "arabica_invites_created_total", 124 + Help: "Total number of invites created", 125 + }) 126 + 127 + LikesTotal = promauto.NewCounterVec(prometheus.CounterOpts{ 128 + Name: "arabica_likes_total", 129 + Help: "Total number of like operations", 130 + }, []string{"operation"}) 131 + 132 + CommentsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ 133 + Name: "arabica_comments_total", 134 + Help: "Total number of comment operations", 135 + }, []string{"operation"}) 136 + 137 + ReportsTotal = promauto.NewCounter(prometheus.CounterOpts{ 138 + Name: "arabica_reports_total", 139 + Help: "Total number of user reports submitted", 140 + }) 141 + ) 142 + 143 + // NormalizePath reduces high-cardinality path labels by replacing dynamic 144 + // segments with placeholders. This keeps the metric label space bounded. 145 + func NormalizePath(path string) string { 146 + // Static assets - collapse into one label 147 + if len(path) > 8 && path[:8] == "/static/" { 148 + return "/static/*" 149 + } 150 + 151 + // Known patterns with IDs - normalize the dynamic segments 152 + // Routes like /brews/{id}, /beans/{id}, /profile/{actor}, etc. 153 + segments := splitPath(path) 154 + if len(segments) < 2 { 155 + return path 156 + } 157 + 158 + switch segments[0] { 159 + case "brews": 160 + if len(segments) == 2 { 161 + if segments[1] == "new" || segments[1] == "export" { 162 + return path 163 + } 164 + return "/brews/:id" 165 + } 166 + if len(segments) == 3 && segments[2] == "edit" { 167 + return "/brews/:id/edit" 168 + } 169 + case "beans", "roasters", "grinders", "brewers": 170 + if len(segments) == 2 { 171 + return "/" + segments[0] + "/:id" 172 + } 173 + case "profile": 174 + if len(segments) == 2 { 175 + return "/profile/:actor" 176 + } 177 + case "api": 178 + if len(segments) >= 3 { 179 + switch segments[1] { 180 + case "beans", "roasters", "grinders", "brewers", "comments": 181 + if len(segments) == 3 { 182 + return "/api/" + segments[1] + "/:id" 183 + } 184 + case "profile": 185 + if len(segments) == 3 { 186 + return "/api/profile/:actor" 187 + } 188 + case "modals": 189 + if len(segments) == 4 { 190 + if segments[3] == "new" { 191 + return "/api/modals/" + segments[2] + "/new" 192 + } 193 + return "/api/modals/" + segments[2] + "/:id" 194 + } 195 + } 196 + } 197 + } 198 + 199 + return path 200 + } 201 + 202 + func splitPath(path string) []string { 203 + // Skip leading slash 204 + if len(path) > 0 && path[0] == '/' { 205 + path = path[1:] 206 + } 207 + // Split on / 208 + var segments []string 209 + start := 0 210 + for i := 0; i < len(path); i++ { 211 + if path[i] == '/' { 212 + if i > start { 213 + segments = append(segments, path[start:i]) 214 + } 215 + start = i + 1 216 + } 217 + } 218 + if start < len(path) { 219 + segments = append(segments, path[start:]) 220 + } 221 + return segments 222 + }
+70
internal/metrics/metrics_test.go
··· 1 + package metrics 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestNormalizePath(t *testing.T) { 10 + tests := []struct { 11 + input string 12 + expected string 13 + }{ 14 + // Static assets 15 + {"/static/css/output.css", "/static/*"}, 16 + {"/static/js/app.js", "/static/*"}, 17 + 18 + // Exact routes (no normalization needed) 19 + {"/", "/"}, 20 + {"/about", "/about"}, 21 + {"/terms", "/terms"}, 22 + {"/login", "/login"}, 23 + {"/manage", "/manage"}, 24 + {"/brews", "/brews"}, 25 + 26 + // Brews with IDs 27 + {"/brews/abc123", "/brews/:id"}, 28 + {"/brews/abc123/edit", "/brews/:id/edit"}, 29 + {"/brews/new", "/brews/new"}, 30 + {"/brews/export", "/brews/export"}, 31 + 32 + // Entity record views 33 + {"/beans/abc123", "/beans/:id"}, 34 + {"/roasters/abc123", "/roasters/:id"}, 35 + {"/grinders/abc123", "/grinders/:id"}, 36 + {"/brewers/abc123", "/brewers/:id"}, 37 + 38 + // Profile 39 + {"/profile/someone.bsky.social", "/profile/:actor"}, 40 + 41 + // API entity routes 42 + {"/api/beans/abc123", "/api/beans/:id"}, 43 + {"/api/roasters/abc123", "/api/roasters/:id"}, 44 + {"/api/grinders/abc123", "/api/grinders/:id"}, 45 + {"/api/brewers/abc123", "/api/brewers/:id"}, 46 + {"/api/comments/abc123", "/api/comments/:id"}, 47 + 48 + // API profile 49 + {"/api/profile/someone.bsky.social", "/api/profile/:actor"}, 50 + 51 + // Modal routes 52 + {"/api/modals/bean/new", "/api/modals/bean/new"}, 53 + {"/api/modals/bean/abc123", "/api/modals/bean/:id"}, 54 + {"/api/modals/grinder/new", "/api/modals/grinder/new"}, 55 + {"/api/modals/grinder/abc123", "/api/modals/grinder/:id"}, 56 + 57 + // Routes that shouldn't be normalized 58 + {"/api/feed", "/api/feed"}, 59 + {"/api/brews", "/api/brews"}, 60 + {"/api/manage", "/api/manage"}, 61 + {"/api/resolve-handle", "/api/resolve-handle"}, 62 + {"/metrics", "/metrics"}, 63 + } 64 + 65 + for _, tt := range tests { 66 + t.Run(tt.input, func(t *testing.T) { 67 + assert.Equal(t, tt.expected, NormalizePath(tt.input)) 68 + }) 69 + } 70 + }
+7
internal/middleware/logging.go
··· 3 3 import ( 4 4 "net" 5 5 "net/http" 6 + "strconv" 6 7 "strings" 7 8 "time" 8 9 9 10 "arabica/internal/atproto" 11 + "arabica/internal/metrics" 10 12 11 13 "github.com/rs/zerolog" 12 14 ) ··· 100 102 logEvent.Interface("headers", headers) 101 103 102 104 logEvent.Msg("HTTP request") 105 + 106 + // Record Prometheus metrics 107 + normalizedPath := metrics.NormalizePath(r.URL.Path) 108 + metrics.HTTPRequestsTotal.WithLabelValues(r.Method, normalizedPath, strconv.Itoa(rw.statusCode)).Inc() 109 + metrics.HTTPRequestDuration.WithLabelValues(r.Method, normalizedPath).Observe(duration.Seconds()) 103 110 }) 104 111 } 105 112 }
+1
internal/routing/routing.go
··· 121 121 mux.Handle("POST /_mod/unblock", cop.Handler(http.HandlerFunc(h.HandleUnblockUser))) 122 122 mux.Handle("POST /_mod/invite", cop.Handler(http.HandlerFunc(h.HandleCreateInvite))) 123 123 mux.Handle("POST /_mod/dismiss-join", cop.Handler(http.HandlerFunc(h.HandleDismissJoinRequest))) 124 + mux.Handle("GET /_mod/stats", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleAdminStats))) 124 125 125 126 // Static files (must come after specific routes) 126 127 fs := http.FileServer(http.Dir("static"))
+98
internal/web/pages/admin.templ
··· 15 15 PostContent string // Summary of the reported content 16 16 } 17 17 18 + // AdminStats holds aggregate statistics for the admin dashboard 19 + type AdminStats struct { 20 + KnownUsers int 21 + RegisteredUsers int 22 + IndexedRecords int 23 + PendingJoinRequests int 24 + TotalLikes int 25 + TotalComments int 26 + FirehoseConnected bool 27 + RecordsByCollection map[string]int 28 + } 29 + 18 30 type AdminProps struct { 19 31 HiddenRecords []moderation.HiddenRecord 20 32 AuditLog []moderation.AuditEntry 21 33 Reports []EnrichedReport 22 34 BlockedUsers []moderation.BlacklistedUser 23 35 JoinRequests []*boltstore.JoinRequest 36 + Stats AdminStats 24 37 CanHide bool 25 38 CanUnhide bool 26 39 CanViewLogs bool ··· 132 145 </span> 133 146 } 134 147 </button> 148 + <button 149 + type="button" 150 + @click="activeTab = 'stats'" 151 + :class="activeTab === 'stats' ? 'bg-brown-300 text-brown-900 border-brown-400' : 'bg-brown-100 text-brown-600 border-brown-200 hover:bg-brown-200'" 152 + class="px-3 py-1.5 rounded-lg border font-medium text-sm transition-colors" 153 + > 154 + Stats 155 + </button> 135 156 } 136 157 </nav> 137 158 <!-- Hidden Records Tab --> ··· 228 249 </div> 229 250 </div> 230 251 } 252 + } 253 + <!-- Stats Tab (admin only) --> 254 + if props.IsAdmin { 255 + <div x-show="activeTab === 'stats'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> 256 + <div 257 + id="stats-panel" 258 + hx-get="/_mod/stats" 259 + hx-trigger="every 30s" 260 + hx-swap="innerHTML" 261 + > 262 + @AdminStatsContent(props.Stats) 263 + </div> 264 + </div> 231 265 } 232 266 </div> 233 267 } ··· 696 730 </div> 697 731 </div> 698 732 } 733 + 734 + templ AdminStatsContent(stats AdminStats) { 735 + <div class="card card-inner"> 736 + <h2 class="section-title">System Stats</h2> 737 + <div class="grid grid-cols-2 md:grid-cols-4 gap-4"> 738 + @statCard("Known Users", fmt.Sprintf("%d", stats.KnownUsers), "Unique DIDs indexed") 739 + @statCard("Registered Users", fmt.Sprintf("%d", stats.RegisteredUsers), "Feed registry") 740 + @statCard("Indexed Records", fmt.Sprintf("%d", stats.IndexedRecords), "Total records") 741 + @statCard("Pending Joins", fmt.Sprintf("%d", stats.PendingJoinRequests), "Join requests") 742 + @statCard("Total Likes", fmt.Sprintf("%d", stats.TotalLikes), "Across all records") 743 + @statCard("Total Comments", fmt.Sprintf("%d", stats.TotalComments), "Across all records") 744 + if stats.FirehoseConnected { 745 + @statCard("Firehose", "Connected", "Real-time events") 746 + } else { 747 + @statCardWarning("Firehose", "Disconnected", "Not receiving events") 748 + } 749 + </div> 750 + if len(stats.RecordsByCollection) > 0 { 751 + <h3 class="section-title mt-6">Records by Collection</h3> 752 + <div class="grid grid-cols-2 md:grid-cols-3 gap-4"> 753 + for collection, count := range stats.RecordsByCollection { 754 + @statCard(collectionLabel(collection), fmt.Sprintf("%d", count), collection) 755 + } 756 + </div> 757 + } 758 + </div> 759 + } 760 + 761 + templ statCard(title, value, subtitle string) { 762 + <div class="bg-brown-50 border border-brown-200 rounded-lg p-4 text-center"> 763 + <div class="text-2xl font-bold text-brown-900">{ value }</div> 764 + <div class="text-sm font-medium text-brown-700 mt-1">{ title }</div> 765 + <div class="text-xs text-brown-500 mt-0.5">{ subtitle }</div> 766 + </div> 767 + } 768 + 769 + templ statCardWarning(title, value, subtitle string) { 770 + <div class="bg-red-50 border border-red-200 rounded-lg p-4 text-center"> 771 + <div class="text-2xl font-bold text-red-700">{ value }</div> 772 + <div class="text-sm font-medium text-red-600 mt-1">{ title }</div> 773 + <div class="text-xs text-red-500 mt-0.5">{ subtitle }</div> 774 + </div> 775 + } 776 + 777 + func collectionLabel(nsid string) string { 778 + switch nsid { 779 + case "social.arabica.alpha.brew": 780 + return "Brews" 781 + case "social.arabica.alpha.bean": 782 + return "Beans" 783 + case "social.arabica.alpha.roaster": 784 + return "Roasters" 785 + case "social.arabica.alpha.grinder": 786 + return "Grinders" 787 + case "social.arabica.alpha.brewer": 788 + return "Brewers" 789 + case "social.arabica.alpha.like": 790 + return "Likes" 791 + case "social.arabica.alpha.comment": 792 + return "Comments" 793 + default: 794 + return nsid 795 + } 796 + }