├── docs ├── dashboard.png ├── README.md ├── code-of-conduct.md ├── installation.md ├── quick-start.md ├── dashboard.md ├── contributing.md ├── middleware-tracing.md ├── opentelemetry.md ├── request-logging.md ├── troubleshooting.md ├── configuration.md ├── api-reference.md └── faq.md ├── .gitattributes ├── internal ├── dashboard │ ├── ui │ │ ├── src │ │ │ ├── preact-shim.js │ │ │ ├── lib │ │ │ │ ├── utils.ts │ │ │ │ ├── react-compat.ts │ │ │ │ └── api.ts │ │ │ ├── index.tsx │ │ │ ├── components │ │ │ │ ├── ui │ │ │ │ │ ├── skeleton.tsx │ │ │ │ │ ├── separator.tsx │ │ │ │ │ ├── input.tsx │ │ │ │ │ ├── badge.tsx │ │ │ │ │ ├── tooltip.tsx │ │ │ │ │ ├── card.tsx │ │ │ │ │ ├── tabs.tsx │ │ │ │ │ ├── button.tsx │ │ │ │ │ ├── table.tsx │ │ │ │ │ ├── dialog.tsx │ │ │ │ │ └── sheet.tsx │ │ │ │ ├── StatsDashboard.tsx │ │ │ │ ├── app-sidebar.tsx │ │ │ │ ├── SimpleSidebar.tsx │ │ │ │ ├── Sidebar.tsx │ │ │ │ ├── FlameGraph.tsx │ │ │ │ ├── Filters.tsx │ │ │ │ ├── RequestDetails.tsx │ │ │ │ ├── EnvironmentInfo.tsx │ │ │ │ └── ExportImport.tsx │ │ │ ├── hooks │ │ │ │ └── use-mobile.tsx │ │ │ └── styles.css │ │ ├── postcss.config.js │ │ ├── components.json │ │ ├── tsconfig.json │ │ ├── package.json │ │ ├── build.js │ │ ├── README.md │ │ └── tailwind.config.js │ └── static │ │ └── index.html ├── store │ ├── memory_test.go │ ├── mongodb_test.go │ ├── redis_test.go │ ├── postgres_test.go │ ├── sqlite_test.go │ ├── store.go │ ├── store_common_test.go │ ├── memory.go │ ├── factory.go │ └── mongodb.go ├── model │ ├── request_test.go │ └── request.go ├── profiling │ ├── profiler_test.go │ └── flamegraph.go ├── middleware │ ├── otel_test.go │ ├── middleware_test.go │ ├── otel.go │ └── middleware.go └── telemetry │ └── telemetry.go ├── .idea ├── vcs.xml ├── .gitignore ├── inspectionProfiles │ └── profiles_settings.xml ├── modules.xml └── govisual.iml ├── cmd └── examples │ ├── otel │ ├── docker-compose.yaml │ ├── README.md │ └── main.go │ ├── README.md │ ├── multistorage │ ├── Dockerfile │ ├── docker-compose.yml │ ├── README.md │ └── main.go │ ├── basic │ ├── README.md │ └── main.go │ └── tracing │ └── README.md ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── test.yml ├── .gitignore ├── LICENSE.md ├── go.mod └── wrap.go /docs/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doganarif/GoVisual/HEAD/docs/dashboard.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.go linguist-detectable=true 2 | 3 | internal/dashboard/templates/** linguist-generated=true 4 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/preact-shim.js: -------------------------------------------------------------------------------- 1 | import { h, Fragment } from "preact"; 2 | export { h, Fragment }; 3 | -------------------------------------------------------------------------------- /internal/dashboard/ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /internal/store/memory_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestInMemoryStore(t *testing.T) { 8 | store := NewInMemoryStore(10) 9 | runStoreTests(t, store) 10 | } 11 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /cmd/examples/otel/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | jaeger: 3 | image: jaegertracing/all-in-one:latest 4 | environment: 5 | - COLLECTOR_OTLP_ENABLED=true 6 | ports: 7 | - "16686:16686" # UI 8 | - "4317:4317" # OTLP gRPC 9 | - "4318:4318" # OTLP HTTP -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { h, render } from "preact"; 2 | import { App } from "./App"; 3 | 4 | // Mount the app 5 | const root = document.getElementById("app"); 6 | if (root) { 7 | render(, root); 8 | } else { 9 | console.error("Could not find app root element"); 10 | } 11 | -------------------------------------------------------------------------------- /.idea/govisual.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /internal/store/mongodb_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestMongoStoage(t *testing.T) { 9 | uri := os.Getenv("MONGO_URI") 10 | if uri == "" { 11 | t.Skip("MONGO_URI not set; skipping MongoDB test") 12 | } 13 | store, err := NewMongoDBStore(uri, "logs", "request_logs", 10) 14 | if err != nil { 15 | t.Fatalf("failed to create MongoDB store: %v", err) 16 | } 17 | runStoreTests(t, store) 18 | } 19 | -------------------------------------------------------------------------------- /internal/store/redis_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestRedisStore(t *testing.T) { 9 | connStr := os.Getenv("REDIS_CONN") 10 | if connStr == "" { 11 | t.Skip("REDIS_CONN not set; skipping Redis test") 12 | } 13 | 14 | store, err := NewRedisStore(connStr, 10, 3600) 15 | if err != nil { 16 | t.Fatalf("failed to create Redis store: %v", err) 17 | } 18 | 19 | runStoreTests(t, store) 20 | } 21 | -------------------------------------------------------------------------------- /internal/dashboard/ui/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema/components.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/styles.css", 9 | "baseColor": "slate", 10 | "cssVariables": false, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /internal/store/postgres_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestPostgresStore(t *testing.T) { 9 | connStr := os.Getenv("PG_CONN") 10 | if connStr == "" { 11 | t.Skip("PG_CONN not set; skipping PostgreSQL test") 12 | } 13 | 14 | store, err := NewPostgresStore(connStr, "logs", 10) 15 | if err != nil { 16 | t.Fatalf("failed to create Postgres store: %v", err) 17 | } 18 | 19 | runStoreTests(t, store) 20 | } 21 | -------------------------------------------------------------------------------- /internal/store/sqlite_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "database/sql" 5 | "testing" 6 | 7 | _ "github.com/mattn/go-sqlite3" 8 | ) 9 | 10 | func TestSQLiteStore(t *testing.T) { 11 | db, err := sql.Open("sqlite3", ":memory:") 12 | if err != nil { 13 | t.Fatalf("failed to open sqlite3: %v", err) 14 | } 15 | defer db.Close() 16 | 17 | store, err := NewSQLiteStoreWithDB(db, "logs", 10) 18 | if err != nil { 19 | t.Fatalf("failed to create SQLite store: %v", err) 20 | } 21 | 22 | runStoreTests(t, store) 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a problem or unexpected behavior 4 | labels: bug 5 | --- 6 | 7 | ## Description 8 | 9 | What's the bug? What did you expect to happen? 10 | 11 | ## Steps to Reproduce 12 | 13 | 1. … 14 | 2. … 15 | 3. … 16 | 17 | ## Environment 18 | 19 | - OS: 20 | - Go version (if applicable): 21 | - Browser (if applicable): 22 | 23 | ## Logs / Screenshots 24 | 25 | Paste logs or screenshots here (if available). 26 | 27 | ## Additional Context 28 | 29 | Anything else we should know? 30 | 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest a new feature or improvement 4 | labels: enhancement 5 | --- 6 | 7 | ## Summary 8 | 9 | Briefly describe the feature you'd like to see. 10 | 11 | ## Motivation 12 | 13 | Why is this feature useful or necessary? 14 | 15 | ## Proposed Solution 16 | 17 | How do you imagine it working? Optional if unsure. 18 | 19 | ## Alternatives 20 | 21 | Any other approaches you considered? 22 | 23 | ## Additional Context 24 | 25 | Links, screenshots, or examples (if any). 26 | 27 | -------------------------------------------------------------------------------- /cmd/examples/README.md: -------------------------------------------------------------------------------- 1 | # GoVisual Examples 2 | 3 | This directory contains example applications demonstrating GoVisual's features. 4 | 5 | ## Available Examples 6 | 7 | - [Basic Example](basic/) - Simple example demonstrating core GoVisual functionality 8 | - [OpenTelemetry Example](otel/) - Example demonstrating OpenTelemetry integration 9 | - [Multi-Storage Example](multistorage/) - Example showing different storage backends (PostgreSQL, Redis, SQLite, MongoDB) 10 | - [Performance Profiling Example](profiling/) - Advanced performance profiling with flame graphs and bottleneck detection 11 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/hooks/use-mobile.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "preact/hooks"; 2 | 3 | const MOBILE_BREAKPOINT = 768; 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = useState(undefined); 7 | 8 | useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 12 | }; 13 | mql.addEventListener("change", onChange); 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 15 | return () => mql.removeEventListener("change", onChange); 16 | }, []); 17 | 18 | return !!isMobile; 19 | } -------------------------------------------------------------------------------- /internal/store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import "github.com/doganarif/govisual/internal/model" 4 | 5 | // Store defines the interface for all storage backends 6 | type Store interface { 7 | // Add adds a new request log to the store 8 | Add(log *model.RequestLog) 9 | 10 | // Get retrieves a specific request log by its ID 11 | Get(id string) (*model.RequestLog, bool) 12 | 13 | // GetAll returns all stored request logs 14 | GetAll() []*model.RequestLog 15 | 16 | // Clear clears all stored request logs 17 | Clear() error 18 | 19 | // GetLatest returns the n most recent request logs 20 | GetLatest(n int) []*model.RequestLog 21 | 22 | // Close closes any open connections 23 | Close() error 24 | } 25 | -------------------------------------------------------------------------------- /cmd/examples/multistorage/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | # Copy go.mod and go.sum first to leverage Docker cache 6 | COPY go.mod go.sum ./ 7 | RUN go mod download 8 | 9 | # Copy the source code 10 | COPY . . 11 | 12 | # Build the application 13 | RUN CGO_ENABLED=0 GOOS=linux go build -o /govisual-multistorage ./cmd/examples/multistorage 14 | 15 | # Use a minimal alpine image for the final container 16 | FROM alpine:latest 17 | 18 | # Add ca-certificates for HTTPS 19 | RUN apk --no-cache add ca-certificates 20 | 21 | WORKDIR /app 22 | 23 | # Copy the binary from the builder stage 24 | COPY --from=builder /govisual-multistorage . 25 | 26 | # Set the entrypoint 27 | ENTRYPOINT ["/app/govisual-multistorage"] 28 | 29 | # Expose port 8080 30 | EXPOSE 8080 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | 20 | # IDE files 21 | .vscode/ 22 | .idea/ 23 | *.swp 24 | *.swo 25 | 26 | # OS files 27 | .DS_Store 28 | Thumbs.db 29 | 30 | # Node.js dependencies 31 | node_modules/ 32 | package-lock.json 33 | 34 | # Built frontend files (these are committed for Go embed) 35 | # internal/dashboard/static/dashboard.js 36 | # internal/dashboard/static/styles.css 37 | 38 | # Example binaries 39 | cmd/examples/*/profiling-example 40 | test-build -------------------------------------------------------------------------------- /internal/dashboard/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GoVisual Dashboard 7 | 8 | 19 | 20 | 21 |
22 |
Loading GoVisual Dashboard...
23 |
24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /internal/dashboard/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "lib": ["DOM", "ESNext"], 6 | "jsx": "react-jsx", 7 | "jsxImportSource": "preact", 8 | "moduleResolution": "node", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "resolveJsonModule": true, 14 | "allowSyntheticDefaultImports": true, 15 | "paths": { 16 | "@/*": ["./src/*"], 17 | "react": ["./node_modules/@preact/compat"], 18 | "react-dom": ["./node_modules/@preact/compat"], 19 | "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"] 20 | }, 21 | "baseUrl": "." 22 | }, 23 | "include": ["src/**/*"], 24 | "exclude": ["node_modules", "build"] 25 | } -------------------------------------------------------------------------------- /cmd/examples/basic/README.md: -------------------------------------------------------------------------------- 1 | # Basic GoVisual Example 2 | 3 | This example demonstrates the core functionality of GoVisual. 4 | 5 | ## Running the Example 6 | 7 | ```bash 8 | go run main.go 9 | ``` 10 | 11 | This will start a server on port 8080 with the following endpoints: 12 | 13 | - `/` - Home page with links to API endpoints 14 | - `/api/hello` - Simple JSON response (100ms delay) 15 | - `/api/slow` - Slow response (500ms delay) 16 | - `/api/error` - Error response (500 status code) 17 | 18 | ## Accessing the Dashboard 19 | 20 | Once the server is running, you can access the GoVisual dashboard at [http://localhost:8080/\_\_viz](http://localhost:8080/__viz). 21 | 22 | ## Features Demonstrated 23 | 24 | - Request visualization 25 | - Response timing 26 | - Status code tracking 27 | - Request and response body logging 28 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/lib/react-compat.ts: -------------------------------------------------------------------------------- 1 | // React compatibility layer for Preact 2 | import { h, ComponentChildren, VNode } from "preact"; 3 | import { forwardRef as preactForwardRef } from "preact/compat"; 4 | 5 | // Re-export Preact compat as React for Radix UI components 6 | export * from "preact/compat"; 7 | 8 | // Type compatibility helpers 9 | export type ReactNode = ComponentChildren; 10 | export type ReactElement = VNode; 11 | 12 | // Helper to make Radix UI components work with Preact 13 | export function createPreactComponent

( 14 | Component: any, 15 | displayName?: string 16 | ) { 17 | const PreactComponent = preactForwardRef((props, ref) => { 18 | return h(Component, { ...props, ref }); 19 | }); 20 | 21 | if (displayName) { 22 | PreactComponent.displayName = displayName; 23 | } 24 | 25 | return PreactComponent; 26 | } 27 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Separator = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >( 10 | ( 11 | { className, orientation = "horizontal", decorative = true, ...props }, 12 | ref 13 | ) => ( 14 | 25 | ) 26 | ) 27 | Separator.displayName = SeparatorPrimitive.Root.displayName 28 | 29 | export { Separator } 30 | -------------------------------------------------------------------------------- /internal/dashboard/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "govisual-dashboard", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "node build.js --watch", 8 | "build": "node build.js", 9 | "typecheck": "tsc --noEmit" 10 | }, 11 | "dependencies": { 12 | "@preact/compat": "^18.3.1", 13 | "@preact/signals": "^1.2.2", 14 | "@radix-ui/react-dialog": "^1.1.15", 15 | "@radix-ui/react-separator": "^1.1.7", 16 | "@radix-ui/react-slot": "^1.2.3", 17 | "@radix-ui/react-tabs": "^1.1.13", 18 | "@radix-ui/react-tooltip": "^1.2.8", 19 | "class-variance-authority": "^0.7.1", 20 | "clsx": "^2.1.0", 21 | "d3": "^7.8.5", 22 | "lucide-preact": "^0.544.0", 23 | "lucide-react": "^0.544.0", 24 | "preact": "^10.19.3", 25 | "tailwind-merge": "^3.3.1", 26 | "vaul": "^1.1.2" 27 | }, 28 | "devDependencies": { 29 | "@types/d3": "^7.4.3", 30 | "esbuild": "^0.19.11", 31 | "tailwindcss": "^3.4.0", 32 | "typescript": "^5.3.3" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2025 Arif Dogan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # GoVisual Documentation 2 | 3 | Welcome to the GoVisual documentation. This documentation provides comprehensive information on how to use, configure, and extend GoVisual. 4 | 5 | ## Contents 6 | 7 | ### Getting Started 8 | 9 | - [Installation](installation.md) 10 | - [Quick Start Guide](quick-start.md) 11 | - [Configuration Options](configuration.md) 12 | 13 | ### Features 14 | 15 | - [Dashboard](dashboard.md) 16 | - [Request Logging](request-logging.md) 17 | - [Middleware Tracing](middleware-tracing.md) 18 | 19 | ### Integration 20 | 21 | - [OpenTelemetry Integration](opentelemetry.md) 22 | - [Storage Backends](storage-backends.md) - _New!_ 23 | 24 | ### Examples 25 | 26 | - [Basic Example](../cmd/examples/basic/README.md) 27 | - [OpenTelemetry Example](../cmd/examples/otel/README.md) 28 | - [Multi-Storage Example](../cmd/examples/multistorage/README.md) - _New!_ 29 | 30 | ### Reference 31 | 32 | - [API Reference](api-reference.md) 33 | - [Troubleshooting](troubleshooting.md) 34 | - [Frequently Asked Questions](faq.md) 35 | 36 | ## Contributing 37 | 38 | - [Contributing Guide](contributing.md) 39 | - [Code of Conduct](code-of-conduct.md) 40 | -------------------------------------------------------------------------------- /internal/model/request_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestNewRequestLog(t *testing.T) { 10 | req, err := http.NewRequest("POST", "http://localhost:8080/test-path?foo=bar", strings.NewReader("body-content")) 11 | if err != nil { 12 | t.Fatalf("failed to create request: %v", err) 13 | } 14 | req.Header.Set("X-Test-Header", "HeaderValue") 15 | 16 | log := NewRequestLog(req) 17 | 18 | if log.ID == "" { 19 | t.Error("expected ID to be generated, got empty string") 20 | } 21 | 22 | if log.Method != "POST" { 23 | t.Errorf("expected method to be POST, got %s", log.Method) 24 | } 25 | 26 | if log.Path != "/test-path" { 27 | t.Errorf("expected method to be /test-path, got %s", log.Path) 28 | } 29 | 30 | if log.Query != "foo=bar" { 31 | t.Errorf("expected query to be foo=bar, got %s", log.Query) 32 | } 33 | 34 | if log.RequestHeaders.Get("X-Test-Header") != "HeaderValue" { 35 | t.Errorf("expected request header to Header Value, got %s", log.RequestHeaders.Get("X-Test-Header")) 36 | } 37 | 38 | if log.Timestamp.IsZero() { 39 | t.Errorf("expected timestamp set, got zero value") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/store/store_common_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/doganarif/govisual/internal/model" 8 | ) 9 | 10 | func runStoreTests(t *testing.T, store Store) { 11 | defer store.Clear() 12 | defer store.Close() 13 | 14 | // Create a log entry 15 | log := &model.RequestLog{ 16 | ID: "test-1", 17 | Timestamp: time.Now(), 18 | Method: "GET", 19 | Path: "/test", 20 | StatusCode: 200, 21 | } 22 | 23 | store.Add(log) 24 | 25 | // Test Get 26 | got, ok := store.Get("test-1") 27 | if !ok || got.ID != "test-1" { 28 | t.Errorf("expected to get log with ID 'test-1', got %+v", got) 29 | } 30 | 31 | // Test GetAll 32 | all := store.GetAll() 33 | if len(all) != 1 { 34 | t.Errorf("expected 1 log in GetAll, got %d", len(all)) 35 | } 36 | 37 | // Test GetLatest 38 | latest := store.GetLatest(1) 39 | if len(latest) != 1 || latest[0].ID != "test-1" { 40 | t.Errorf("expected to get latest log with ID 'test-1', got %+v", latest) 41 | } 42 | 43 | // Test Clear 44 | if err := store.Clear(); err != nil { 45 | t.Errorf("Clear failed: %v", err) 46 | } 47 | if len(store.GetAll()) != 0 { 48 | t.Error("expected no logs after Clear") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /cmd/examples/otel/README.md: -------------------------------------------------------------------------------- 1 | # GoVisual OpenTelemetry Example 2 | 3 | This example demonstrates GoVisual integration with OpenTelemetry. 4 | 5 | ## Prerequisites 6 | 7 | - Docker and Docker Compose for running Jaeger 8 | 9 | ## Setup 10 | 11 | 1. Start Jaeger: 12 | 13 | ```bash 14 | docker-compose up -d 15 | ``` 16 | 17 | 2. Run the example: 18 | 19 | ```bash 20 | go run main.go 21 | ``` 22 | 23 | This will start a server on port 8080 with OpenTelemetry instrumentation enabled. 24 | 25 | ## Endpoints 26 | 27 | - `/` - Home page with links to API endpoints 28 | - `/api/users` - Returns user data with nested spans 29 | - `/api/search?q=test` - Search endpoint with query parameter as a span attribute 30 | - `/api/health` - Health check endpoint (not traced due to ignore path) 31 | 32 | ## Accessing the Dashboards 33 | 34 | - GoVisual Dashboard: [http://localhost:8080/\_\_viz](http://localhost:8080/__viz) 35 | - Jaeger UI: [http://localhost:16686](http://localhost:16686) 36 | 37 | ## Features Demonstrated 38 | 39 | - GoVisual integration with OpenTelemetry 40 | - Creating custom spans and nested spans 41 | - Adding attributes to spans 42 | - Path-based span filtering 43 | - Viewing traces in Jaeger 44 | - Correlating requests between GoVisual and Jaeger 45 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import { h, ComponentChildren } from "preact"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import { cn } from "@/lib/utils"; 4 | 5 | const badgeVariants = cva( 6 | "inline-flex items-center rounded-full border border-slate-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2", 7 | { 8 | variants: { 9 | variant: { 10 | default: 11 | "border-transparent bg-slate-900 text-slate-50 hover:bg-slate-900/80", 12 | secondary: 13 | "border-transparent bg-slate-100 text-slate-900 hover:bg-slate-100/80", 14 | destructive: 15 | "border-transparent bg-red-500 text-slate-50 hover:bg-red-500/80", 16 | outline: "text-slate-950", 17 | }, 18 | }, 19 | defaultVariants: { 20 | variant: "default", 21 | }, 22 | } 23 | ); 24 | 25 | export interface BadgeProps 26 | extends h.JSX.HTMLAttributes, 27 | VariantProps { 28 | children?: ComponentChildren; 29 | } 30 | 31 | function Badge({ className, variant, ...props }: BadgeProps) { 32 | return ( 33 |

34 | ); 35 | } 36 | 37 | export { Badge, badgeVariants }; 38 | -------------------------------------------------------------------------------- /internal/dashboard/ui/build.js: -------------------------------------------------------------------------------- 1 | import * as esbuild from "esbuild"; 2 | import { readFileSync, writeFileSync } from "fs"; 3 | import { execSync } from "child_process"; 4 | 5 | const isWatch = process.argv.includes("--watch"); 6 | 7 | // Build CSS with Tailwind 8 | console.log("Building CSS..."); 9 | execSync("npx tailwindcss -i ./src/styles.css -o ../static/styles.css", { 10 | stdio: "inherit", 11 | }); 12 | 13 | const buildOptions = { 14 | entryPoints: ["src/index.tsx"], 15 | bundle: true, 16 | minify: !isWatch, 17 | sourcemap: isWatch, 18 | outfile: "../static/dashboard.js", 19 | format: "iife", 20 | platform: "browser", 21 | target: ["es2020"], 22 | loader: { 23 | ".tsx": "tsx", 24 | ".ts": "ts", 25 | ".css": "text", 26 | }, 27 | jsxFactory: "h", 28 | jsxFragment: "Fragment", 29 | inject: ["./src/preact-shim.js"], 30 | alias: { 31 | react: "@preact/compat", 32 | "react-dom": "@preact/compat", 33 | "react/jsx-runtime": "preact/jsx-runtime", 34 | }, 35 | define: { 36 | "process.env.NODE_ENV": isWatch ? '"development"' : '"production"', 37 | }, 38 | }; 39 | 40 | if (isWatch) { 41 | const ctx = await esbuild.context(buildOptions); 42 | await ctx.watch(); 43 | console.log("Watching for changes..."); 44 | } else { 45 | await esbuild.build(buildOptions); 46 | console.log("Build complete"); 47 | } 48 | -------------------------------------------------------------------------------- /internal/profiling/profiler_test.go: -------------------------------------------------------------------------------- 1 | package profiling 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestConcurrentCPUProfiling(t *testing.T) { 12 | profiler := NewProfiler(100) 13 | profiler.SetEnabled(true) 14 | profiler.SetProfileType(ProfileCPU) 15 | profiler.SetThreshold(1 * time.Millisecond) // Very low threshold for testing 16 | 17 | // Simulate concurrent requests 18 | var wg sync.WaitGroup 19 | numRequests := 10 20 | 21 | for i := 0; i < numRequests; i++ { 22 | wg.Add(1) 23 | go func(requestID string) { 24 | defer wg.Done() 25 | 26 | ctx := profiler.StartProfiling(context.Background(), requestID) 27 | 28 | // Simulate some work 29 | time.Sleep(5 * time.Millisecond) 30 | 31 | metrics := profiler.EndProfiling(ctx) 32 | 33 | // For requests that meet the threshold, we should have CPU profile data 34 | // Note: Only one request will actually get CPU profiling due to global state 35 | if metrics != nil { 36 | t.Logf("Request %s got CPU profile data: %d bytes", requestID, len(metrics.CPUProfile)) 37 | } 38 | }(fmt.Sprintf("req-%d", i)) 39 | } 40 | 41 | wg.Wait() 42 | 43 | // Verify that no CPU profiling is still active 44 | if profiler.activeCPUProfile != nil { 45 | t.Errorf("CPU profiling should not be active after all requests completed") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /docs/code-of-conduct.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as contributors and maintainers of GoVisual pledge to make participation in our project a harassment-free experience for everyone, regardless of background, identity, or level of experience. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior include: 18 | 19 | - The use of sexualized language or imagery 20 | - Trolling, insulting/derogatory comments, and personal attacks 21 | - Public or private harassment 22 | - Publishing others' private information without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Enforcement 26 | 27 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project maintainers. All complaints will be reviewed and investigated promptly and fairly. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, issues, and other contributions that are not aligned with this Code of Conduct. 30 | 31 | ## Attribution 32 | 33 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 34 | -------------------------------------------------------------------------------- /internal/model/request.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/doganarif/govisual/internal/profiling" 8 | ) 9 | 10 | type RequestLog struct { 11 | ID string `json:"ID" bson:"_id"` 12 | Timestamp time.Time `json:"Timestamp"` 13 | Method string `json:"Method"` 14 | Path string `json:"Path"` 15 | Query string `json:"Query"` 16 | RequestHeaders http.Header `json:"RequestHeaders"` 17 | ResponseHeaders http.Header `json:"ResponseHeaders"` 18 | StatusCode int `json:"StatusCode"` 19 | Duration int64 `json:"Duration"` 20 | RequestBody string `json:"RequestBody,omitempty"` 21 | ResponseBody string `json:"ResponseBody,omitempty"` 22 | Error string `json:"Error,omitempty"` 23 | MiddlewareTrace []map[string]interface{} `json:"MiddlewareTrace,omitempty"` 24 | RouteTrace map[string]interface{} `json:"RouteTrace,omitempty"` 25 | PerformanceMetrics *profiling.Metrics `json:"PerformanceMetrics,omitempty" bson:"PerformanceMetrics,omitempty"` 26 | } 27 | 28 | func NewRequestLog(req *http.Request) *RequestLog { 29 | return &RequestLog{ 30 | ID: generateID(), 31 | Timestamp: time.Now(), 32 | Method: req.Method, 33 | Path: req.URL.Path, 34 | Query: req.URL.RawQuery, 35 | RequestHeaders: req.Header, 36 | } 37 | } 38 | 39 | func generateID() string { 40 | return time.Now().Format("20060102-150405.000000") 41 | } 42 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | This guide covers installing GoVisual for your Go web applications. 4 | 5 | ## Requirements 6 | 7 | - Go 1.19 or higher 8 | - (Optional) PostgreSQL for persistent storage backend 9 | - (Optional) Redis for high-performance storage backend 10 | - (Optional) OpenTelemetry collector for telemetry data export 11 | 12 | ## Using Go Modules (Recommended) 13 | 14 | The simplest way to install GoVisual is via Go modules: 15 | 16 | ```bash 17 | go get github.com/doganarif/govisual 18 | ``` 19 | 20 | ## Manual Installation 21 | 22 | You can also manually clone the repository: 23 | 24 | ```bash 25 | git clone https://github.com/doganarif/govisual.git 26 | cd govisual 27 | go install 28 | ``` 29 | 30 | ## Verifying Installation 31 | 32 | Create a simple test application to verify that GoVisual is working correctly: 33 | 34 | ```go 35 | package main 36 | 37 | import ( 38 | "fmt" 39 | "net/http" 40 | "github.com/doganarif/govisual" 41 | ) 42 | 43 | func main() { 44 | mux := http.NewServeMux() 45 | 46 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 47 | fmt.Fprintf(w, "Hello, GoVisual!") 48 | }) 49 | 50 | // Wrap with GoVisual 51 | handler := govisual.Wrap(mux) 52 | 53 | fmt.Println("Server starting at http://localhost:8080") 54 | fmt.Println("GoVisual dashboard available at http://localhost:8080/__viz") 55 | http.ListenAndServe(":8080", handler) 56 | } 57 | ``` 58 | 59 | If everything is working correctly, you should be able to: 60 | 61 | 1. Access your application at http://localhost:8080/ 62 | 2. See the GoVisual dashboard at http://localhost:8080/\_\_viz 63 | 64 | ## Next Steps 65 | 66 | Once GoVisual is installed, check out the [Quick Start Guide](quick-start.md) to learn how to use it in your application. 67 | -------------------------------------------------------------------------------- /internal/middleware/otel_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "go.opentelemetry.io/otel" 11 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 12 | "go.opentelemetry.io/otel/trace" 13 | ) 14 | 15 | func TestOTelMiddleware_ServeHTTP(t *testing.T) { 16 | // Set up a test tracer provider and span recorder 17 | sr := sdktrace.NewSimpleSpanProcessor(&testSpanExporter{}) 18 | tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr)) 19 | defer func() { _ = tp.Shutdown(context.Background()) }() 20 | otel.SetTracerProvider(tp) 21 | 22 | // Dummy handler to respond with status 23 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 | w.WriteHeader(http.StatusAccepted) 25 | io.WriteString(w, "traced") 26 | }) 27 | 28 | // Wrap with OTelMiddleware 29 | middleware := NewOTelMiddleware(handler, "test-service", "v1.0") 30 | 31 | req := httptest.NewRequest("GET", "/hello", nil) 32 | req.Header.Set("User-Agent", "TestClient") 33 | rec := httptest.NewRecorder() 34 | 35 | middleware.ServeHTTP(rec, req) 36 | 37 | // Basic output check 38 | if rec.Code != http.StatusAccepted { 39 | t.Errorf("expected status %d, got %d", http.StatusAccepted, rec.Code) 40 | } 41 | } 42 | 43 | // testSpanExporter implements a basic SpanExporter to print spans (you can extend this to assert attributes) 44 | type testSpanExporter struct{} 45 | 46 | func (e *testSpanExporter) ExportSpans(_ context.Context, spans []sdktrace.ReadOnlySpan) error { 47 | for _, span := range spans { 48 | if span.Name() == "" { 49 | panic("span name is empty") 50 | } 51 | if span.SpanKind() != trace.SpanKindServer { 52 | panic("span kind is not server") 53 | } 54 | } 55 | return nil 56 | } 57 | 58 | func (e *testSpanExporter) Shutdown(_ context.Context) error { 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /internal/dashboard/ui/README.md: -------------------------------------------------------------------------------- 1 | # GoVisual Dashboard (Preact) 2 | 3 | Modern, fast dashboard built with Preact and shadcn-ui components. 4 | 5 | ## Development 6 | 7 | ### Prerequisites 8 | 9 | - Node.js 18+ 10 | - npm or yarn 11 | 12 | ### Setup 13 | 14 | ```bash 15 | # Install dependencies 16 | npm install 17 | 18 | # Build for production 19 | npm run build 20 | 21 | # Watch mode for development 22 | npm run dev 23 | ``` 24 | 25 | ## Architecture 26 | 27 | - **Preact**: Lightweight React alternative (3KB) 28 | - **shadcn-ui**: Modern, accessible UI components 29 | - **Tailwind CSS**: Utility-first CSS framework 30 | - **esbuild**: Fast JavaScript bundler 31 | - **TypeScript**: Type safety 32 | 33 | ## Project Structure 34 | 35 | ``` 36 | src/ 37 | ├── components/ 38 | │ ├── ui/ # shadcn-ui components 39 | │ ├── RequestTable.tsx # Request list component 40 | │ ├── RequestDetails.tsx # Request details view 41 | │ └── PerformanceProfiler.tsx # Performance profiling UI 42 | ├── lib/ 43 | │ ├── api.ts # API client 44 | │ └── utils.ts # Utility functions 45 | ├── App.tsx # Main application component 46 | └── index.tsx # Entry point 47 | ``` 48 | 49 | ## Features 50 | 51 | - **Real-time Updates**: Live request monitoring via SSE 52 | - **Performance Profiling**: CPU, memory, and goroutine tracking 53 | - **Flame Graphs**: Interactive D3.js visualization 54 | - **Bottleneck Detection**: Automatic performance issue identification 55 | - **Clean UI**: Modern, minimal design with no gradients 56 | - **Fast**: Built with performance in mind 57 | 58 | ## Building for Production 59 | 60 | The build process: 61 | 62 | 1. Compiles TypeScript to JavaScript 63 | 2. Bundles all dependencies with esbuild 64 | 3. Processes CSS with Tailwind 65 | 4. Outputs to `../static/` directory 66 | 67 | The Go backend embeds these static files for distribution. 68 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: GoVisual Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | services: 13 | postgres: 14 | image: postgres:15 15 | env: 16 | POSTGRES_USER: testuser 17 | POSTGRES_PASSWORD: testpass 18 | POSTGRES_DB: testdb 19 | ports: 20 | - 5432:5432 21 | options: >- 22 | --health-cmd pg_isready 23 | --health-interval 10s 24 | --health-timeout 5s 25 | --health-retries 5 26 | 27 | redis: 28 | image: redis:7 29 | ports: 30 | - 6379:6379 31 | options: >- 32 | --health-cmd "redis-cli ping" 33 | --health-interval 10s 34 | --health-timeout 5s 35 | --health-retries 5 36 | 37 | mongodb: 38 | image: mongo:6 39 | env: 40 | MONGO_INITDB_ROOT_USERNAME: root 41 | MONGO_INITDB_ROOT_PASSWORD: root 42 | ports: 43 | - 27017:27017 44 | options: >- 45 | --health-cmd "mongosh --eval 'db.runCommand({ ping: 1 })' -u root -p root --authenticationDatabase admin" 46 | --health-interval 10s 47 | --health-timeout 5s 48 | --health-retries 5 49 | 50 | steps: 51 | - name: Checkout code 52 | uses: actions/checkout@v4 53 | 54 | - name: Set up Go 55 | uses: actions/setup-go@v5 56 | with: 57 | go-version: 1.22 58 | 59 | - name: Install SQLite driver (CGO) 60 | run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev 61 | 62 | - name: Run tests 63 | env: 64 | PG_CONN: postgres://testuser:testpass@localhost:5432/testdb?sslmode=disable 65 | REDIS_CONN: redis://localhost:6379/0 66 | MONGO_URI: mongodb://root:root@localhost:27017 67 | run: go test -v ./... 68 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/doganarif/govisual 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/go-redis/redis/v8 v8.11.5 7 | github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7 8 | github.com/lib/pq v1.10.9 9 | go.opentelemetry.io/otel v1.35.0 10 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 11 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 12 | go.opentelemetry.io/otel/sdk v1.35.0 13 | go.opentelemetry.io/otel/trace v1.35.0 14 | google.golang.org/grpc v1.72.1 15 | ) 16 | 17 | require ( 18 | github.com/golang/snappy v1.0.0 // indirect 19 | github.com/google/uuid v1.6.0 // indirect 20 | github.com/klauspost/compress v1.16.7 // indirect 21 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect 22 | github.com/xdg-go/scram v1.1.2 // indirect 23 | github.com/xdg-go/stringprep v1.0.4 // indirect 24 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect 25 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 26 | golang.org/x/crypto v0.37.0 // indirect 27 | golang.org/x/sync v0.13.0 // indirect 28 | ) 29 | 30 | require ( 31 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 32 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 33 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 34 | github.com/go-logr/logr v1.4.2 // indirect 35 | github.com/go-logr/stdr v1.2.2 // indirect 36 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect 37 | github.com/mattn/go-sqlite3 v1.14.28 38 | go.mongodb.org/mongo-driver/v2 v2.2.1 39 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 40 | go.opentelemetry.io/proto/otlp v1.1.0 // indirect 41 | golang.org/x/net v0.35.0 // indirect 42 | golang.org/x/sys v0.32.0 // indirect 43 | golang.org/x/text v0.24.0 // indirect 44 | google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect 45 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect 46 | google.golang.org/protobuf v1.36.5 // indirect 47 | ) 48 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import { h, ComponentChildren } from "preact"; 2 | import { forwardRef } from "preact/compat"; 3 | import { cn } from "@/lib/utils"; 4 | 5 | interface CardProps extends h.JSX.HTMLAttributes { 6 | children?: ComponentChildren; 7 | } 8 | 9 | const Card = forwardRef( 10 | ({ className, ...props }, ref) => ( 11 |
19 | ) 20 | ); 21 | Card.displayName = "Card"; 22 | 23 | const CardHeader = forwardRef( 24 | ({ className, ...props }, ref) => ( 25 |
30 | ) 31 | ); 32 | CardHeader.displayName = "CardHeader"; 33 | 34 | const CardTitle = forwardRef( 35 | ({ className, ...props }, ref) => ( 36 |
44 | ) 45 | ); 46 | CardTitle.displayName = "CardTitle"; 47 | 48 | const CardDescription = forwardRef( 49 | ({ className, ...props }, ref) => ( 50 |
55 | ) 56 | ); 57 | CardDescription.displayName = "CardDescription"; 58 | 59 | const CardContent = forwardRef( 60 | ({ className, ...props }, ref) => ( 61 |
62 | ) 63 | ); 64 | CardContent.displayName = "CardContent"; 65 | 66 | const CardFooter = forwardRef( 67 | ({ className, ...props }, ref) => ( 68 |
73 | ) 74 | ); 75 | CardFooter.displayName = "CardFooter"; 76 | 77 | export { 78 | Card, 79 | CardHeader, 80 | CardFooter, 81 | CardTitle, 82 | CardDescription, 83 | CardContent, 84 | }; 85 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TabsPrimitive from "@radix-ui/react-tabs" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Tabs = TabsPrimitive.Root 7 | 8 | const TabsList = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | TabsList.displayName = TabsPrimitive.List.displayName 22 | 23 | const TabsTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 35 | )) 36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 37 | 38 | const TabsContent = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 50 | )) 51 | TabsContent.displayName = TabsPrimitive.Content.displayName 52 | 53 | export { Tabs, TabsList, TabsTrigger, TabsContent } 54 | -------------------------------------------------------------------------------- /docs/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start Guide 2 | 3 | This guide will help you integrate GoVisual into your Go web application in just a few minutes. 4 | 5 | ## Basic Integration 6 | 7 | The core concept of GoVisual is simple - wrap your existing HTTP handler with `govisual.Wrap()`: 8 | 9 | ```go 10 | package main 11 | 12 | import ( 13 | "fmt" 14 | "net/http" 15 | "github.com/doganarif/govisual" 16 | ) 17 | 18 | func main() { 19 | // Create your regular HTTP handler/mux 20 | mux := http.NewServeMux() 21 | 22 | // Add your routes 23 | mux.HandleFunc("/api/hello", func(w http.ResponseWriter, r *http.Request) { 24 | fmt.Fprintf(w, "Hello, World!") 25 | }) 26 | 27 | // Wrap with GoVisual (with default settings) 28 | handler := govisual.Wrap(mux) 29 | 30 | // Start your server with the wrapped handler 31 | http.ListenAndServe(":8080", handler) 32 | } 33 | ``` 34 | 35 | That's it! Your application will now have a built-in request visualization dashboard accessible at `http://localhost:8080/__viz`. 36 | 37 | ## Configuration Options 38 | 39 | For more control, you can provide options to the `Wrap` function: 40 | 41 | ```go 42 | handler := govisual.Wrap( 43 | mux, 44 | govisual.WithMaxRequests(100), // Store the last 100 requests 45 | govisual.WithDashboardPath("/__debug"), // Custom dashboard path 46 | govisual.WithRequestBodyLogging(true), // Log request bodies 47 | govisual.WithResponseBodyLogging(true), // Log response bodies 48 | govisual.WithIgnorePaths("/health", "/metrics") // Don't log these paths 49 | ) 50 | ``` 51 | 52 | ## Accessing the Dashboard 53 | 54 | By default, the dashboard is available at `http://localhost:8080/__viz` (or whatever port your application is running on). 55 | 56 | The dashboard shows: 57 | 58 | - A list of all captured HTTP requests 59 | - Detailed information about each request and response 60 | - Request and response bodies (if enabled) 61 | - Middleware execution trace 62 | - Timing information 63 | 64 | ## Next Steps 65 | 66 | - [Configuration Options](configuration.md) - Learn about all available configuration options 67 | - [Storage Backends](storage-backends.md) - Configure persistent storage for request logs 68 | - [OpenTelemetry Integration](opentelemetry.md) - Export trace data to OpenTelemetry collectors 69 | - [Examples](../cmd/examples/basic/README.md) - Run the included examples 70 | -------------------------------------------------------------------------------- /cmd/examples/basic/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/doganarif/govisual" 12 | ) 13 | 14 | func main() { 15 | var port int 16 | flag.IntVar(&port, "port", 8080, "HTTP server port") 17 | flag.Parse() 18 | 19 | // Create a simple HTTP server 20 | mux := http.NewServeMux() 21 | 22 | // Add example routes 23 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 24 | if r.URL.Path != "/" { 25 | http.NotFound(w, r) 26 | return 27 | } 28 | w.Header().Set("Content-Type", "text/html") 29 | fmt.Fprintf(w, ` 30 |

GoVisual Basic Example

31 |

Visit /__viz to access the request visualizer

32 |

API Endpoints:

33 | 38 | `) 39 | }) 40 | 41 | mux.HandleFunc("/api/hello", func(w http.ResponseWriter, r *http.Request) { 42 | time.Sleep(100 * time.Millisecond) 43 | w.Header().Set("Content-Type", "application/json") 44 | json.NewEncoder(w).Encode(map[string]string{ 45 | "message": "Hello, World!", 46 | }) 47 | }) 48 | 49 | mux.HandleFunc("/api/slow", func(w http.ResponseWriter, r *http.Request) { 50 | time.Sleep(500 * time.Millisecond) 51 | w.Header().Set("Content-Type", "application/json") 52 | json.NewEncoder(w).Encode(map[string]string{ 53 | "message": "This was slow", 54 | "duration": "500ms", 55 | }) 56 | }) 57 | 58 | mux.HandleFunc("/api/error", func(w http.ResponseWriter, r *http.Request) { 59 | w.Header().Set("Content-Type", "application/json") 60 | w.WriteHeader(http.StatusInternalServerError) 61 | json.NewEncoder(w).Encode(map[string]string{ 62 | "error": "Internal Server Error", 63 | }) 64 | }) 65 | 66 | // Wrap with GoVisual 67 | handler := govisual.Wrap( 68 | mux, 69 | govisual.WithRequestBodyLogging(true), 70 | govisual.WithResponseBodyLogging(true), 71 | ) 72 | 73 | // Start the server 74 | addr := fmt.Sprintf(":%d", port) 75 | log.Printf("Server started at http://localhost%s", addr) 76 | log.Printf("Visit http://localhost%s/__viz to see the dashboard", addr) 77 | log.Fatal(http.ListenAndServe(addr, handler)) 78 | } 79 | -------------------------------------------------------------------------------- /internal/store/memory.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/doganarif/govisual/internal/model" 7 | ) 8 | 9 | type InMemoryStore struct { 10 | logs []*model.RequestLog 11 | capacity int 12 | size int 13 | next int 14 | mu sync.RWMutex 15 | } 16 | 17 | func NewInMemoryStore(capacity int) *InMemoryStore { 18 | if capacity <= 0 { 19 | capacity = 100 20 | } 21 | 22 | return &InMemoryStore{ 23 | logs: make([]*model.RequestLog, capacity), 24 | capacity: capacity, 25 | size: 0, 26 | next: 0, 27 | } 28 | } 29 | 30 | func (s *InMemoryStore) Add(log *model.RequestLog) { 31 | s.mu.Lock() 32 | defer s.mu.Unlock() 33 | 34 | s.logs[s.next] = log 35 | 36 | s.next = (s.next + 1) % s.capacity 37 | 38 | if s.size < s.capacity { 39 | s.size++ 40 | } 41 | } 42 | 43 | func (s *InMemoryStore) Get(id string) (*model.RequestLog, bool) { 44 | s.mu.RLock() 45 | defer s.mu.RUnlock() 46 | 47 | for _, log := range s.logs[:s.size] { 48 | if log != nil && log.ID == id { 49 | return log, true 50 | } 51 | } 52 | 53 | return nil, false 54 | } 55 | 56 | func (s *InMemoryStore) GetAll() []*model.RequestLog { 57 | s.mu.RLock() 58 | defer s.mu.RUnlock() 59 | 60 | result := make([]*model.RequestLog, 0, s.size) 61 | 62 | if s.size < s.capacity { 63 | for i := 0; i < s.size; i++ { 64 | result = append(result, s.logs[i]) 65 | } 66 | return result 67 | } 68 | 69 | for i := s.next; i < s.capacity; i++ { 70 | result = append(result, s.logs[i]) 71 | } 72 | for i := 0; i < s.next; i++ { 73 | result = append(result, s.logs[i]) 74 | } 75 | 76 | return result 77 | } 78 | 79 | func (s *InMemoryStore) GetLatest(n int) []*model.RequestLog { 80 | all := s.GetAll() 81 | 82 | if len(all) <= n { 83 | return all 84 | } 85 | 86 | return all[len(all)-n:] 87 | } 88 | 89 | // Clear clears all stored request logs 90 | func (s *InMemoryStore) Clear() error { 91 | s.mu.Lock() 92 | defer s.mu.Unlock() 93 | 94 | if s.size == 0 { 95 | return nil 96 | } 97 | 98 | s.logs = make([]*model.RequestLog, s.capacity) 99 | s.size = 0 100 | s.next = 0 101 | return nil 102 | } 103 | 104 | // Close implements the Store interface but does nothing for in-memory store 105 | func (s *InMemoryStore) Close() error { 106 | // Nothing to do for in-memory store 107 | return nil 108 | } 109 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/StatsDashboard.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; 3 | 4 | interface StatsProps { 5 | requests: any[]; 6 | } 7 | 8 | export function StatsDashboard({ requests }: StatsProps) { 9 | const calculateStats = () => { 10 | const total = requests.length; 11 | const success = requests.filter( 12 | (r) => r.StatusCode >= 200 && r.StatusCode < 300 13 | ).length; 14 | const redirect = requests.filter( 15 | (r) => r.StatusCode >= 300 && r.StatusCode < 400 16 | ).length; 17 | const clientError = requests.filter( 18 | (r) => r.StatusCode >= 400 && r.StatusCode < 500 19 | ).length; 20 | const serverError = requests.filter((r) => r.StatusCode >= 500).length; 21 | const avgDuration = 22 | total > 0 23 | ? Math.round(requests.reduce((sum, r) => sum + r.Duration, 0) / total) 24 | : 0; 25 | 26 | return { total, success, redirect, clientError, serverError, avgDuration }; 27 | }; 28 | 29 | const stats = calculateStats(); 30 | 31 | const statCards = [ 32 | { 33 | label: "Total Requests", 34 | value: stats.total, 35 | }, 36 | { 37 | label: "Success (2xx)", 38 | value: stats.success, 39 | }, 40 | { 41 | label: "Redirect (3xx)", 42 | value: stats.redirect, 43 | }, 44 | { 45 | label: "Client Error (4xx)", 46 | value: stats.clientError, 47 | }, 48 | { 49 | label: "Server Error (5xx)", 50 | value: stats.serverError, 51 | }, 52 | { 53 | label: "Avg Response Time", 54 | value: `${stats.avgDuration}ms`, 55 | }, 56 | ]; 57 | 58 | return ( 59 |
60 | {statCards.map((stat, index) => ( 61 | 66 | 67 |
68 | {stat.value} 69 |
70 |

71 | {stat.label} 72 |

73 |
74 |
75 | ))} 76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-slate-900 text-slate-50 hover:bg-slate-900/90 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90", 14 | destructive: 15 | "bg-red-500 text-slate-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90", 16 | outline: 17 | "border border-slate-200 bg-white hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50", 18 | secondary: 19 | "bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80", 20 | ghost: 21 | "hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50", 22 | link: "text-slate-900 underline-offset-4 hover:underline dark:text-slate-50", 23 | }, 24 | size: { 25 | default: "h-10 px-4 py-2", 26 | sm: "h-9 rounded-md px-3", 27 | lg: "h-11 rounded-md px-8", 28 | icon: "h-10 w-10", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ); 37 | 38 | export interface ButtonProps 39 | extends React.ButtonHTMLAttributes, 40 | VariantProps { 41 | asChild?: boolean; 42 | } 43 | 44 | const Button = React.forwardRef( 45 | ({ className, variant, size, asChild = false, ...props }, ref) => { 46 | const Comp = asChild ? Slot : "button"; 47 | return ( 48 | 53 | ); 54 | } 55 | ); 56 | Button.displayName = "Button"; 57 | 58 | export { Button, buttonVariants }; 59 | -------------------------------------------------------------------------------- /internal/telemetry/telemetry.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "time" 7 | 8 | "go.opentelemetry.io/otel" 9 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace" 10 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" 11 | "go.opentelemetry.io/otel/propagation" 12 | "go.opentelemetry.io/otel/sdk/resource" 13 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 14 | semconv "go.opentelemetry.io/otel/semconv/v1.24.0" 15 | "google.golang.org/grpc" 16 | "google.golang.org/grpc/credentials/insecure" 17 | ) 18 | 19 | // InitTracer initializes an OTLP exporter, and configures the corresponding trace provider. 20 | func InitTracer(ctx context.Context, serviceName, serviceVersion, endpoint string) (func(context.Context) error, error) { 21 | res, err := resource.New(ctx, 22 | resource.WithAttributes( 23 | semconv.ServiceName(serviceName), 24 | semconv.ServiceVersion(serviceVersion), 25 | ), 26 | ) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | // If no endpoint is provided, use a sensible default for local development 32 | if endpoint == "" { 33 | endpoint = "localhost:4317" 34 | } 35 | 36 | // Create gRPC connection to collector 37 | conn, err := grpc.DialContext(ctx, endpoint, 38 | grpc.WithTransportCredentials(insecure.NewCredentials()), 39 | grpc.WithBlock(), 40 | ) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | // Create OTLP exporter 46 | traceExporter, err := otlptrace.New(ctx, 47 | otlptracegrpc.NewClient( 48 | otlptracegrpc.WithGRPCConn(conn), 49 | ), 50 | ) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | // Create trace provider with the exporter 56 | bsp := sdktrace.NewBatchSpanProcessor(traceExporter) 57 | tracerProvider := sdktrace.NewTracerProvider( 58 | sdktrace.WithSampler(sdktrace.AlwaysSample()), 59 | sdktrace.WithResource(res), 60 | sdktrace.WithSpanProcessor(bsp), 61 | ) 62 | otel.SetTracerProvider(tracerProvider) 63 | 64 | // Set global propagator to tracecontext (W3C) 65 | otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( 66 | propagation.TraceContext{}, 67 | propagation.Baggage{}, 68 | )) 69 | 70 | // Return a shutdown function that can be called to clean up resources 71 | return func(ctx context.Context) error { 72 | ctx, cancel := context.WithTimeout(ctx, 5*time.Second) 73 | defer cancel() 74 | if err := tracerProvider.Shutdown(ctx); err != nil { 75 | log.Printf("Error shutting down tracer provider: %v", err) 76 | return err 77 | } 78 | return nil 79 | }, nil 80 | } 81 | -------------------------------------------------------------------------------- /internal/dashboard/ui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: ["class"], 4 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 5 | theme: { 6 | extend: { 7 | colors: { 8 | border: 'hsl(214 32% 91%)', 9 | input: 'hsl(214 32% 91%)', 10 | ring: 'hsl(221 83% 53%)', 11 | background: 'hsl(0 0% 100%)', 12 | foreground: 'hsl(222 47% 11%)', 13 | primary: { 14 | DEFAULT: 'hsl(221 83% 53%)', 15 | foreground: 'hsl(0 0% 100%)' 16 | }, 17 | secondary: { 18 | DEFAULT: 'hsl(214 32% 96%)', 19 | foreground: 'hsl(222 47% 11%)' 20 | }, 21 | destructive: { 22 | DEFAULT: 'hsl(0 84% 60%)', 23 | foreground: 'hsl(0 0% 100%)' 24 | }, 25 | muted: { 26 | DEFAULT: 'hsl(214 32% 96%)', 27 | foreground: 'hsl(215 14% 45%)' 28 | }, 29 | accent: { 30 | DEFAULT: 'hsl(214 32% 96%)', 31 | foreground: 'hsl(222 47% 11%)' 32 | }, 33 | popover: { 34 | DEFAULT: 'hsl(0 0% 100%)', 35 | foreground: 'hsl(222 47% 11%)' 36 | }, 37 | card: { 38 | DEFAULT: 'hsl(0 0% 100%)', 39 | foreground: 'hsl(222 47% 11%)' 40 | }, 41 | success: { 42 | DEFAULT: 'hsl(142 76% 36%)', 43 | foreground: 'hsl(0 0% 100%)' 44 | }, 45 | warning: { 46 | DEFAULT: 'hsl(45 93% 47%)', 47 | foreground: 'hsl(222 47% 11%)' 48 | }, 49 | sidebar: { 50 | DEFAULT: 'hsl(var(--sidebar-background))', 51 | foreground: 'hsl(var(--sidebar-foreground))', 52 | primary: 'hsl(var(--sidebar-primary))', 53 | 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', 54 | accent: 'hsl(var(--sidebar-accent))', 55 | 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', 56 | border: 'hsl(var(--sidebar-border))', 57 | ring: 'hsl(var(--sidebar-ring))' 58 | } 59 | }, 60 | borderRadius: { 61 | lg: '0.5rem', 62 | md: '0.375rem', 63 | sm: '0.25rem' 64 | }, 65 | animation: { 66 | 'fade-in': 'fadeIn 0.2s ease-in-out', 67 | 'slide-in': 'slideIn 0.2s ease-out' 68 | }, 69 | keyframes: { 70 | fadeIn: { 71 | '0%': { 72 | opacity: 0 73 | }, 74 | '100%': { 75 | opacity: 1 76 | } 77 | }, 78 | slideIn: { 79 | '0%': { 80 | transform: 'translateY(-10px)', 81 | opacity: 0 82 | }, 83 | '100%': { 84 | transform: 'translateY(0)', 85 | opacity: 1 86 | } 87 | } 88 | } 89 | } 90 | }, 91 | plugins: [], 92 | }; 93 | -------------------------------------------------------------------------------- /internal/middleware/middleware_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/doganarif/govisual/internal/model" 10 | ) 11 | 12 | // mockStore implements store.Store for testing 13 | type mockStore struct { 14 | logs []*model.RequestLog 15 | } 16 | 17 | func (m *mockStore) Add(log *model.RequestLog) { 18 | m.logs = append(m.logs, log) 19 | } 20 | 21 | func (m *mockStore) Get(id string) (*model.RequestLog, bool) { 22 | for _, log := range m.logs { 23 | if log.ID == id { 24 | return log, true 25 | } 26 | } 27 | return nil, false 28 | } 29 | 30 | func (m *mockStore) GetAll() []*model.RequestLog { 31 | return m.logs 32 | } 33 | 34 | func (m *mockStore) Clear() error { 35 | m.logs = nil 36 | return nil 37 | } 38 | 39 | func (m *mockStore) GetLatest(n int) []*model.RequestLog { 40 | if n >= len(m.logs) { 41 | return m.logs 42 | } 43 | return m.logs[len(m.logs)-n:] 44 | } 45 | 46 | func (m *mockStore) Close() error { 47 | return nil 48 | } 49 | 50 | // mockPathMatcher implements PathMatcher 51 | type mockPathMatcher struct{} 52 | 53 | func (m *mockPathMatcher) ShouldIgnorePath(path string) bool { 54 | return false 55 | } 56 | 57 | func TestWrapMiddleware(t *testing.T) { 58 | store := &mockStore{} 59 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 60 | w.WriteHeader(http.StatusCreated) 61 | w.Write([]byte("hello world")) 62 | }) 63 | 64 | wrapped := Wrap(handler, store, true, true, &mockPathMatcher{}) 65 | 66 | req := httptest.NewRequest("POST", "/test?x=1", strings.NewReader("sample-body")) 67 | req.Header.Set("X-Test", "test") 68 | rec := httptest.NewRecorder() 69 | 70 | wrapped.ServeHTTP(rec, req) 71 | 72 | if len(store.logs) != 1 { 73 | t.Fatalf("expected 1 log entry, got %d", len(store.logs)) 74 | } 75 | log := store.logs[0] 76 | 77 | if log.Method != "POST" { 78 | t.Errorf("expected Method POST, got %s", log.Method) 79 | } 80 | if log.Path != "/test" { 81 | t.Errorf("expected Path /test, got %s", log.Path) 82 | } 83 | if log.RequestBody != "sample-body" { 84 | t.Errorf("expected RequestBody to be 'sample-body', got '%s'", log.RequestBody) 85 | } 86 | if log.ResponseBody != "hello world" { 87 | t.Errorf("expected ResponseBody to be 'hello world', got '%s'", log.ResponseBody) 88 | } 89 | if log.StatusCode != http.StatusCreated { 90 | t.Errorf("expected status %d, got %d", http.StatusCreated, log.StatusCode) 91 | } 92 | if log.Duration < 0 { 93 | t.Errorf("expected Duration > 0, got %d", log.Duration) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /cmd/examples/multistorage/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | build: 6 | context: ../../.. 7 | dockerfile: cmd/examples/multistorage/Dockerfile 8 | ports: 9 | - "8080:8080" 10 | environment: 11 | - PORT=8080 12 | # Comment/uncomment the following environment variables to switch storage backends 13 | 14 | # Use in-memory storage (default, no env vars needed) 15 | # - GOVISUAL_STORAGE_TYPE=sqlite 16 | # 17 | # Use Sqlite 18 | # - GOVISUAL_STORAGE_TYPE=sqlite 19 | # - GOVISUAL_SQLITE_DBPATH=/data/govisual.db 20 | # - GOVISUAL_SQLITE_TABLE=govisual_requests 21 | 22 | # Use PostgreSQL storage 23 | # - GOVISUAL_STORAGE_TYPE=postgres 24 | # - GOVISUAL_PG_CONN=postgres://postgres:postgres@postgres:5432/govisual?sslmode=disable 25 | # - GOVISUAL_PG_TABLE=govisual_requests 26 | 27 | # Use Redis storage 28 | - GOVISUAL_STORAGE_TYPE=redis 29 | - GOVISUAL_REDIS_CONN=redis://redis:6379/0 30 | - GOVISUAL_REDIS_TTL=86400 31 | 32 | # Use MongoDB storage 33 | # - GOVISUAL_STORAGE_TYPE=mongodb 34 | # - GOVISUAL_MONGO_URI=mongodb://root:root@localhost:27017/ 35 | # - GOVISUAL_MONGO_DATABASE=logs 36 | # - GOVISUAL_MONGO_COLLECTION=request_logs 37 | depends_on: 38 | - postgres 39 | - redis 40 | networks: 41 | - govisual-net 42 | 43 | postgres: 44 | image: postgres:16-alpine 45 | environment: 46 | - POSTGRES_USER=postgres 47 | - POSTGRES_PASSWORD=postgres 48 | - POSTGRES_DB=govisual 49 | ports: 50 | - "5432:5432" 51 | volumes: 52 | - postgres-data:/var/lib/postgresql/data 53 | networks: 54 | - govisual-net 55 | healthcheck: 56 | test: ["CMD-SHELL", "pg_isready -U postgres"] 57 | interval: 5s 58 | timeout: 5s 59 | retries: 5 60 | 61 | redis: 62 | image: redis:7-alpine 63 | ports: 64 | - "6379:6379" 65 | volumes: 66 | - redis-data:/data 67 | networks: 68 | - govisual-net 69 | healthcheck: 70 | test: ["CMD", "redis-cli", "ping"] 71 | interval: 5s 72 | timeout: 5s 73 | retries: 5 74 | 75 | mongo: 76 | image: "mongo" 77 | environment: 78 | - MONGO_INITDB_ROOT_USERNAME=root 79 | - MONGO_INITDB_ROOT_PASSWORD=root 80 | ports: 81 | - "27017:27017" 82 | volumes: 83 | - mongo-data:/data/db 84 | networks: 85 | - govisual-net 86 | 87 | volumes: 88 | postgres-data: 89 | redis-data: 90 | mongo-data: 91 | 92 | networks: 93 | govisual-net: 94 | driver: bridge 95 | -------------------------------------------------------------------------------- /internal/middleware/otel.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "go.opentelemetry.io/otel" 7 | "go.opentelemetry.io/otel/attribute" 8 | "go.opentelemetry.io/otel/propagation" 9 | "go.opentelemetry.io/otel/trace" 10 | ) 11 | 12 | // OTelMiddleware wraps an http.Handler with OpenTelemetry instrumentation 13 | type OTelMiddleware struct { 14 | tracer trace.Tracer 15 | propagator propagation.TextMapPropagator 16 | handler http.Handler 17 | serviceVersion string 18 | } 19 | 20 | // NewOTelMiddleware creates a new OpenTelemetry middleware 21 | func NewOTelMiddleware(handler http.Handler, serviceName, serviceVersion string) *OTelMiddleware { 22 | return &OTelMiddleware{ 23 | tracer: otel.Tracer(serviceName), 24 | propagator: otel.GetTextMapPropagator(), 25 | handler: handler, 26 | serviceVersion: serviceVersion, 27 | } 28 | } 29 | 30 | // ServeHTTP implements the http.Handler interface 31 | func (m *OTelMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { 32 | // Extract any existing context from the request 33 | ctx := r.Context() 34 | ctx = m.propagator.Extract(ctx, propagation.HeaderCarrier(r.Header)) 35 | 36 | // Start a new span 37 | spanName := r.Method + " " + r.URL.Path 38 | opts := []trace.SpanStartOption{ 39 | trace.WithAttributes( 40 | attribute.String("http.method", r.Method), 41 | attribute.String("http.url", r.URL.String()), 42 | attribute.String("http.host", r.Host), 43 | attribute.String("http.user_agent", r.UserAgent()), 44 | attribute.String("http.flavor", r.Proto), 45 | attribute.String("service.version", m.serviceVersion), 46 | ), 47 | trace.WithSpanKind(trace.SpanKindServer), 48 | } 49 | 50 | ctx, span := m.tracer.Start(ctx, spanName, opts...) 51 | defer span.End() 52 | 53 | // Create wrapped response writer to capture status code 54 | wrw := &wrappedResponseWriter{ResponseWriter: w, statusCode: http.StatusOK} 55 | 56 | // Execute handler with context 57 | m.handler.ServeHTTP(wrw, r.WithContext(ctx)) 58 | 59 | // Add status code attribute to span 60 | span.SetAttributes(attribute.Int("http.status_code", wrw.statusCode)) 61 | } 62 | 63 | // wrappedResponseWriter captures the status code 64 | type wrappedResponseWriter struct { 65 | http.ResponseWriter 66 | statusCode int 67 | } 68 | 69 | // WriteHeader captures the status code 70 | func (wrw *wrappedResponseWriter) WriteHeader(statusCode int) { 71 | wrw.statusCode = statusCode 72 | wrw.ResponseWriter.WriteHeader(statusCode) 73 | } 74 | 75 | // Write captures writes to the response 76 | func (wrw *wrappedResponseWriter) Write(b []byte) (int, error) { 77 | return wrw.ResponseWriter.Write(b) 78 | } 79 | -------------------------------------------------------------------------------- /cmd/examples/tracing/README.md: -------------------------------------------------------------------------------- 1 | # Middleware Tracing Example 2 | 3 | This example demonstrates the comprehensive middleware tracking capabilities of GoVisual. 4 | 5 | ## Features Demonstrated 6 | 7 | - **Middleware Chain Tracking**: See how requests flow through multiple middleware layers 8 | - **SQL Query Tracking**: Monitor database queries with timing and results 9 | - **HTTP Call Tracking**: Track external API calls 10 | - **Custom Trace Points**: Add custom trace entries for specific operations 11 | - **Performance Metrics**: View detailed performance data for each request 12 | 13 | ## Running the Example 14 | 15 | ```bash 16 | go run main.go 17 | ``` 18 | 19 | The server will start on http://localhost:8090 20 | 21 | ## Dashboard 22 | 23 | Open http://localhost:8090/\_\_viz to view the dashboard. 24 | 25 | ## Test Endpoints 26 | 27 | 1. **Basic Request**: http://localhost:8090/ 28 | 29 | - Simple JSON response 30 | - Shows basic middleware execution 31 | 32 | 2. **Database Query**: http://localhost:8090/api/users 33 | 34 | - Executes SQL queries 35 | - Shows database interaction in traces 36 | 37 | 3. **Slow Operation**: http://localhost:8090/api/slow 38 | 39 | - Multi-step operation with timing 40 | - Shows nested trace entries 41 | 42 | 4. **External API**: http://localhost:8090/api/external 43 | - Simulates external HTTP calls 44 | - Shows HTTP tracking in traces 45 | 46 | ## Viewing Traces 47 | 48 | 1. Make some requests to the test endpoints 49 | 2. Open the dashboard at http://localhost:8090/\_\_viz 50 | 3. Go to the "Trace" tab 51 | 4. Select a request to see its detailed execution trace 52 | 53 | ## Trace Information Includes 54 | 55 | - **Middleware Stack**: See each middleware that processed the request 56 | - **Execution Timeline**: Visual timeline of all operations 57 | - **SQL Queries**: Full query text, duration, and row counts 58 | - **HTTP Calls**: External API calls with status and timing 59 | - **Custom Events**: Application-specific trace points 60 | - **Performance Metrics**: CPU usage, memory allocation, and bottlenecks 61 | 62 | ## Customizing Traces 63 | 64 | You can add custom trace points in your handlers: 65 | 66 | ```go 67 | tracer := middleware.GetTracer(r.Context()) 68 | if tracer != nil { 69 | tracer.StartTrace("Operation Name", "custom", map[string]interface{}{ 70 | "custom_field": "value", 71 | }) 72 | // ... your operation ... 73 | tracer.EndTrace(nil) 74 | } 75 | ``` 76 | 77 | ## SQL Query Tracking 78 | 79 | SQL queries are automatically tracked when using the profiling-enabled database drivers. The traces show: 80 | 81 | - Query text 82 | - Execution duration 83 | - Number of rows affected/returned 84 | - Any errors encountered 85 | -------------------------------------------------------------------------------- /cmd/examples/multistorage/README.md: -------------------------------------------------------------------------------- 1 | # GoVisual Multi-Storage Example 2 | 3 | This example demonstrates how to use GoVisual with different storage backends: 4 | 5 | - In-memory storage (default) 6 | - PostgreSQL 7 | - Redis 8 | 9 | ## Running the Example 10 | 11 | You can run the example using Docker Compose, which will set up all necessary services: 12 | 13 | ```bash 14 | # Start all services 15 | docker-compose up -d 16 | 17 | # View logs 18 | docker-compose logs -f 19 | ``` 20 | 21 | By default, the application will use in-memory storage. You can change the storage backend by modifying the environment variables in `docker-compose.yml`: 22 | 23 | ### Using In-Memory Storage (Default) 24 | 25 | ```yaml 26 | environment: 27 | - PORT=8080 28 | # No additional environment variables needed for in-memory storage 29 | ``` 30 | 31 | ### Using PostgreSQL Storage 32 | 33 | ```yaml 34 | environment: 35 | - PORT=8080 36 | - GOVISUAL_STORAGE_TYPE=postgres 37 | - GOVISUAL_PG_CONN=postgres://postgres:postgres@postgres:5432/govisual?sslmode=disable 38 | - GOVISUAL_PG_TABLE=govisual_requests 39 | ``` 40 | 41 | ### Using Redis Storage 42 | 43 | ```yaml 44 | environment: 45 | - PORT=8080 46 | - GOVISUAL_STORAGE_TYPE=redis 47 | - GOVISUAL_REDIS_CONN=redis://redis:6379/0 48 | - GOVISUAL_REDIS_TTL=86400 49 | ``` 50 | 51 | ### Using SQLite Storage 52 | 53 | ```yaml 54 | environment: 55 | - PORT=8080 56 | - GOVISUAL_STORAGE_TYPE=sqlite 57 | - GOVISUAL_SQLITE_DBPATH=/data/govisual.db 58 | - GOVISUAL_SQLITE_TABLE=govisual_requests 59 | ``` 60 | 61 | ### Using MongoDB Storage 62 | 63 | ```yaml 64 | GOVISUAL_STORAGE_TYPE=mongodb 65 | GOVISUAL_MONGO_URI=mongodb://root:root@localhost:27017/ 66 | GOVISUAL_MONGO_DATABASE=logs 67 | GOVISUAL_MONGO_COLLECTION=request_logs 68 | ``` 69 | 70 | ## Accessing the Application 71 | 72 | Once the application is running, you can access it at: 73 | 74 | - Main application: http://localhost:8080 75 | - GoVisual dashboard: http://localhost:8080/\_\_viz 76 | 77 | ## Available Endpoints 78 | 79 | - `GET /`: Home page with instructions 80 | - `GET /api/users`: List users (JSON) 81 | - `POST /api/users`: Create user (expects JSON body) 82 | - `GET /api/products`: List products (JSON) 83 | - `POST /api/products`: Create product (expects JSON body) 84 | 85 | ## Running Without Docker 86 | 87 | If you prefer to run the example without Docker, you can use: 88 | 89 | ```bash 90 | # In-memory storage (default) 91 | go run main.go 92 | 93 | # PostgreSQL storage 94 | GOVISUAL_STORAGE_TYPE=postgres \ 95 | GOVISUAL_PG_CONN="postgres://postgres:postgres@localhost:5432/govisual?sslmode=disable" \ 96 | go run main.go 97 | 98 | # Redis storage 99 | GOVISUAL_STORAGE_TYPE=redis \ 100 | GOVISUAL_REDIS_CONN="redis://localhost:6379/0" \ 101 | go run main.go 102 | ``` 103 | -------------------------------------------------------------------------------- /internal/store/factory.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | // StorageType represents the type of storage backend to use 10 | type StorageType string 11 | 12 | const ( 13 | // StorageTypeMemory represents in-memory storage 14 | StorageTypeMemory StorageType = "memory" 15 | 16 | // StorageTypePostgres represents PostgreSQL storage 17 | StorageTypePostgres StorageType = "postgres" 18 | 19 | // StorageTypeRedis represents Redis storage 20 | StorageTypeRedis StorageType = "redis" 21 | 22 | // StorageTypeSQLite represents SQLite storage 23 | StorageTypeSQLite StorageType = "sqlite" 24 | 25 | // StorageTypeSQLiteWithDB represents SQLite storage with existing connection 26 | StorageTypeSQLiteWithDB StorageType = "sqlite_with_db" 27 | 28 | // StorageTypeMongoDB represents MongoDB storage 29 | StorageTypeMongoDB StorageType = "mongodb" 30 | ) 31 | 32 | // StorageConfig represents configuration options for storage backends 33 | type StorageConfig struct { 34 | // Type specifies which storage backend to use 35 | Type StorageType 36 | 37 | // Capacity is the maximum number of requests to store (applicable to memory store) 38 | Capacity int 39 | 40 | // ConnectionString is the database connection string (applicable to DB stores) 41 | ConnectionString string 42 | 43 | // TableName is the table name for SQL databases 44 | TableName string 45 | 46 | // TTL is the time-to-live for entries in Redis 47 | TTL int 48 | 49 | // ExistingDB is an existing database connection (applicable to StorageTypeSQLiteWithDB) 50 | ExistingDB *sql.DB 51 | } 52 | 53 | // NewStore creates a new storage backend based on configuration 54 | func NewStore(config *StorageConfig) (Store, error) { 55 | switch config.Type { 56 | case StorageTypeMemory: 57 | return NewInMemoryStore(config.Capacity), nil 58 | 59 | case StorageTypePostgres: 60 | return NewPostgresStore(config.ConnectionString, config.TableName, config.Capacity) 61 | 62 | case StorageTypeRedis: 63 | return NewRedisStore(config.ConnectionString, config.Capacity, config.TTL) 64 | 65 | case StorageTypeSQLite: 66 | // The original SQLite store with automatic driver registration 67 | return NewSQLiteStore(config.ConnectionString, config.TableName, config.Capacity) 68 | 69 | case StorageTypeSQLiteWithDB: 70 | // New SQLite store that accepts an existing DB connection 71 | if config.ExistingDB == nil { 72 | return nil, fmt.Errorf("existing DB connection is required for sqlite_with_db storage type") 73 | } 74 | return NewSQLiteStoreWithDB(config.ExistingDB, config.TableName, config.Capacity) 75 | 76 | case StorageTypeMongoDB: 77 | mongoMetaData := strings.Split(config.TableName, ".") 78 | if len(mongoMetaData) < 2 { 79 | return nil, fmt.Errorf("failed to get mongodb connection metadata") 80 | } 81 | database := mongoMetaData[0] 82 | collection := mongoMetaData[1] 83 | return NewMongoDBStore(config.ConnectionString, database, collection, config.Capacity) 84 | default: 85 | return nil, fmt.Errorf("unknown storage type: %s", config.Type) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /docs/dashboard.md: -------------------------------------------------------------------------------- 1 | # GoVisual Dashboard 2 | 3 | The GoVisual dashboard provides a real-time view of HTTP requests flowing through your application. 4 | 5 | ![GoVisual Dashboard](dashboard.png) 6 | 7 | ## Accessing the Dashboard 8 | 9 | By default, the dashboard is available at `http://localhost:/__viz`. You can customize this path using the `WithDashboardPath` option: 10 | 11 | ```go 12 | handler := govisual.Wrap( 13 | mux, 14 | govisual.WithDashboardPath("/__debug"), 15 | ) 16 | ``` 17 | 18 | ## Dashboard Features 19 | 20 | ### Request Table 21 | 22 | The main view displays a table of recent HTTP requests with the following information: 23 | 24 | - **Method**: HTTP method (GET, POST, PUT, etc.) 25 | - **Path**: The request URL path 26 | - **Status**: HTTP status code (color-coded) 27 | - **Time**: Response time in milliseconds 28 | - **Timestamp**: When the request was received 29 | 30 | The table automatically updates as new requests come in. 31 | 32 | ### Request Details 33 | 34 | Clicking on a request in the table reveals detailed information: 35 | 36 | #### Request Tab 37 | 38 | - Full URL (including query parameters) 39 | - HTTP method 40 | - Headers 41 | - Request body (if enabled) 42 | - Cookies 43 | 44 | #### Response Tab 45 | 46 | - Status code 47 | - Headers 48 | - Response body (if enabled) 49 | - Content type 50 | - Response size 51 | 52 | #### Timing Tab 53 | 54 | - Total response time 55 | - Time spent in each middleware 56 | - Network latency 57 | 58 | #### Middleware Trace Tab 59 | 60 | - Visual representation of middleware execution 61 | - Time spent in each middleware 62 | - Call hierarchy 63 | 64 | ### Filtering and Searching 65 | 66 | The dashboard includes filtering capabilities: 67 | 68 | - Filter by HTTP method (GET, POST, etc.) 69 | - Filter by status code or status code range (2xx, 4xx, etc.) 70 | - Search by URL path 71 | - Filter by time range 72 | 73 | ### Dashboard Controls 74 | 75 | - **Clear All**: Remove all requests from the view 76 | - **Auto-refresh**: Toggle automatic updates 77 | - **Columns**: Show/hide specific columns 78 | - **Export**: Download request data as JSON 79 | 80 | ## Browser Support 81 | 82 | The GoVisual dashboard is compatible with all modern browsers: 83 | 84 | - Chrome (recommended) 85 | - Firefox 86 | - Safari 87 | - Edge 88 | 89 | ## Troubleshooting 90 | 91 | If you can't access the dashboard: 92 | 93 | 1. Verify that the dashboard path is correct 94 | 2. Check that your application is running 95 | 3. Ensure no path conflict with your application's routes 96 | 4. Check if any security middleware is blocking access 97 | 98 | If requests aren't showing up: 99 | 100 | 1. Ensure the routes are passing through the GoVisual middleware 101 | 2. Check if the routes are in the ignored paths list 102 | 3. Make sure you're sending requests to the instrumented handler 103 | 104 | ## Related Documentation 105 | 106 | - [Configuration Options](configuration.md) - Configure dashboard behavior 107 | - [Request Logging](request-logging.md) - Control what gets logged 108 | - [Middleware Tracing](middleware-tracing.md) - How middleware tracing works 109 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | tr]:last:border-b-0 dark:bg-slate-800/50", 47 | className 48 | )} 49 | {...props} 50 | /> 51 | )) 52 | TableFooter.displayName = "TableFooter" 53 | 54 | const TableRow = React.forwardRef< 55 | HTMLTableRowElement, 56 | React.HTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 | 66 | )) 67 | TableRow.displayName = "TableRow" 68 | 69 | const TableHead = React.forwardRef< 70 | HTMLTableCellElement, 71 | React.ThHTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 |
81 | )) 82 | TableHead.displayName = "TableHead" 83 | 84 | const TableCell = React.forwardRef< 85 | HTMLTableCellElement, 86 | React.TdHTMLAttributes 87 | >(({ className, ...props }, ref) => ( 88 | 93 | )) 94 | TableCell.displayName = "TableCell" 95 | 96 | const TableCaption = React.forwardRef< 97 | HTMLTableCaptionElement, 98 | React.HTMLAttributes 99 | >(({ className, ...props }, ref) => ( 100 |
105 | )) 106 | TableCaption.displayName = "TableCaption" 107 | 108 | export { 109 | Table, 110 | TableHeader, 111 | TableBody, 112 | TableFooter, 113 | TableHead, 114 | TableRow, 115 | TableCell, 116 | TableCaption, 117 | } 118 | -------------------------------------------------------------------------------- /internal/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/doganarif/govisual/internal/model" 11 | "github.com/doganarif/govisual/internal/store" 12 | ) 13 | 14 | // PathMatcher defines an interface for checking if a path should be ignored 15 | type PathMatcher interface { 16 | ShouldIgnorePath(path string) bool 17 | } 18 | 19 | // responseWriter is a wrapper for http.ResponseWriter that captures the status code and response 20 | type responseWriter struct { 21 | http.ResponseWriter 22 | statusCode int 23 | buffer *bytes.Buffer 24 | } 25 | 26 | // WriteHeader captures the status code 27 | func (w *responseWriter) WriteHeader(code int) { 28 | w.statusCode = code 29 | w.ResponseWriter.WriteHeader(code) 30 | } 31 | 32 | // Write captures the response body 33 | func (w *responseWriter) Write(b []byte) (int, error) { 34 | // Write to the buffer 35 | if w.buffer != nil { 36 | w.buffer.Write(b) 37 | } 38 | return w.ResponseWriter.Write(b) 39 | } 40 | 41 | // Wrap wraps an http.Handler with the request visualization middleware 42 | func Wrap(handler http.Handler, store store.Store, logRequestBody, logResponseBody bool, pathMatcher PathMatcher) http.Handler { 43 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 | // Check if the path should be ignored 45 | if pathMatcher != nil && pathMatcher.ShouldIgnorePath(r.URL.Path) { 46 | handler.ServeHTTP(w, r) 47 | return 48 | } 49 | 50 | // Create a new request log 51 | reqLog := model.NewRequestLog(r) 52 | 53 | // Capture request body if enabled 54 | if logRequestBody && r.Body != nil { 55 | // Read the body 56 | bodyBytes, _ := io.ReadAll(r.Body) 57 | r.Body.Close() 58 | 59 | // Store the body in the log 60 | reqLog.RequestBody = string(bodyBytes) 61 | 62 | // Create a new body for the request 63 | r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) 64 | } 65 | 66 | // Create response writer wrapper 67 | var resWriter *responseWriter 68 | if logResponseBody { 69 | resWriter = &responseWriter{ 70 | ResponseWriter: w, 71 | statusCode: 200, // Default status code 72 | buffer: &bytes.Buffer{}, 73 | } 74 | } else { 75 | resWriter = &responseWriter{ 76 | ResponseWriter: w, 77 | statusCode: 200, // Default status code 78 | } 79 | } 80 | 81 | // Record start time 82 | start := time.Now() 83 | 84 | // Call the handler 85 | handler.ServeHTTP(resWriter, r) 86 | 87 | // Calculate duration 88 | duration := time.Since(start) 89 | reqLog.Duration = duration.Milliseconds() 90 | 91 | // Capture response info 92 | reqLog.StatusCode = resWriter.statusCode 93 | 94 | // Extract middleware information from context 95 | if middlewareValue := r.Context().Value("middleware"); middlewareValue != nil { 96 | if middlewareInfo, ok := middlewareValue.(map[string]interface{}); ok { 97 | if stack, ok := middlewareInfo["stack"].([]map[string]interface{}); ok { 98 | reqLog.MiddlewareTrace = stack 99 | } 100 | } 101 | } 102 | 103 | // Extract route trace information 104 | if routeValue := r.Context().Value("route"); routeValue != nil { 105 | if routeStr, ok := routeValue.(string); ok { 106 | var routeInfo map[string]interface{} 107 | if err := json.Unmarshal([]byte(routeStr), &routeInfo); err == nil { 108 | reqLog.RouteTrace = routeInfo 109 | } 110 | } 111 | } 112 | 113 | // Capture response body if enabled 114 | if logResponseBody && resWriter.buffer != nil { 115 | reqLog.ResponseBody = resWriter.buffer.String() 116 | } 117 | 118 | // Store the request log 119 | store.Add(reqLog) 120 | }) 121 | } 122 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to GoVisual 2 | 3 | Thank you for your interest in contributing to GoVisual! This document provides guidelines and instructions for contributing to the project. 4 | 5 | ## Development Setup 6 | 7 | ### Prerequisites 8 | 9 | - Go 1.20 or higher 10 | - Docker and Docker Compose (for running the examples with databases) 11 | - Git 12 | 13 | ### Getting Started 14 | 15 | 1. Fork the repository on GitHub 16 | 2. Clone your fork locally 17 | ```bash 18 | git clone https://github.com/yourusername/govisual.git 19 | cd govisual 20 | ``` 21 | 3. Add the original repository as an upstream remote 22 | ```bash 23 | git remote add upstream https://github.com/doganarif/govisual.git 24 | ``` 25 | 4. Install dependencies 26 | ```bash 27 | go mod download 28 | ``` 29 | 30 | ## Running Tests 31 | 32 | Run the tests with: 33 | 34 | ```bash 35 | go test ./... 36 | ``` 37 | 38 | For tests involving storage backends, you can use the provided Docker Compose files: 39 | 40 | ```bash 41 | # For PostgreSQL tests 42 | cd cmd/examples/multistorage 43 | GOVISUAL_STORAGE_TYPE=postgres \ 44 | GOVISUAL_PG_CONN="postgres://postgres:postgres@localhost:5432/govisual?sslmode=disable" \ 45 | go test ../../internal/store/... 46 | 47 | # For Redis tests 48 | GOVISUAL_STORAGE_TYPE=redis \ 49 | GOVISUAL_REDIS_CONN="redis://localhost:6379/0" \ 50 | go test ../../internal/store/... 51 | ``` 52 | 53 | ## Code Style Guidelines 54 | 55 | GoVisual follows standard Go coding conventions: 56 | 57 | - Run `go fmt` before committing to ensure consistent formatting 58 | - Follow [Effective Go](https://golang.org/doc/effective_go) guidelines 59 | - Use `golint` and `go vet` to check for common issues 60 | - Write meaningful comments, especially for exported functions and types 61 | - Keep functions small and focused on a single responsibility 62 | - Use meaningful variable and function names that describe their purpose 63 | 64 | ## Contribution Workflow 65 | 66 | 1. Create a new branch for your feature or bugfix 67 | 68 | ```bash 69 | git checkout -b feature/your-feature-name 70 | ``` 71 | 72 | 2. Make your changes, following the code style guidelines 73 | 74 | 3. Add tests for your changes 75 | 76 | 4. Run tests to make sure everything works 77 | 78 | ```bash 79 | go test ./... 80 | ``` 81 | 82 | 5. Commit your changes with a clear and descriptive commit message 83 | 84 | ```bash 85 | git commit -m "Add support for new feature X" 86 | ``` 87 | 88 | 6. Push to your fork 89 | 90 | ```bash 91 | git push origin feature/your-feature-name 92 | ``` 93 | 94 | 7. Create a Pull Request against the main repository 95 | 96 | ## Pull Request Guidelines 97 | 98 | - Provide a clear description of the problem you're solving 99 | - Update documentation if necessary 100 | - Add or update tests as appropriate 101 | - Keep PRs focused on a single issue/feature to make them easier to review 102 | - Make sure CI tests pass 103 | 104 | ## Adding Storage Backends 105 | 106 | When adding a new storage backend: 107 | 108 | 1. Implement the `Store` interface in `internal/store/store.go` 109 | 2. Add relevant configuration options in `options.go` 110 | 3. Update factory methods in `internal/store/factory.go` 111 | 4. Add documentation in `docs/storage-backends.md` 112 | 5. Create examples showing usage 113 | 114 | ## Reporting Issues 115 | 116 | When reporting issues, please include: 117 | 118 | - A clear description of the problem 119 | - Steps to reproduce 120 | - Expected vs. actual behavior 121 | - Version of GoVisual you're using 122 | - Go version and OS 123 | - Any relevant logs or error messages 124 | 125 | ## License 126 | 127 | By contributing to GoVisual, you agree that your contributions will be licensed under the project's MIT license. 128 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* Custom animations */ 6 | @keyframes slideUp { 7 | from { 8 | transform: translateY(100%); 9 | } 10 | to { 11 | transform: translateY(0); 12 | } 13 | } 14 | 15 | @keyframes fadeIn { 16 | from { 17 | opacity: 0; 18 | } 19 | to { 20 | opacity: 1; 21 | } 22 | } 23 | 24 | @keyframes scaleIn { 25 | from { 26 | transform: scale(0.95); 27 | opacity: 0; 28 | } 29 | to { 30 | transform: scale(1); 31 | opacity: 1; 32 | } 33 | } 34 | 35 | @layer base { 36 | * { 37 | @apply border-border; 38 | } 39 | body { 40 | @apply bg-background text-foreground; 41 | font-feature-settings: "rlig" 1, "calt" 1; 42 | } 43 | 44 | /* Smooth transitions for interactive elements */ 45 | button, a, input, select, textarea { 46 | @apply transition-all duration-200; 47 | } 48 | 49 | /* Focus styles */ 50 | *:focus-visible { 51 | @apply outline-none ring-2 ring-primary/20 ring-offset-2; 52 | } 53 | :root { 54 | --sidebar-background: 0 0% 98%; 55 | --sidebar-foreground: 240 5.3% 26.1%; 56 | --sidebar-primary: 240 5.9% 10%; 57 | --sidebar-primary-foreground: 0 0% 98%; 58 | --sidebar-accent: 240 4.8% 95.9%; 59 | --sidebar-accent-foreground: 240 5.9% 10%; 60 | --sidebar-border: 220 13% 91%; 61 | --sidebar-ring: 217.2 91.2% 59.8%; 62 | } 63 | } 64 | 65 | @layer components { 66 | /* Animation utilities */ 67 | .animate-in { 68 | animation-fill-mode: both; 69 | animation-duration: 0.5s; 70 | } 71 | 72 | .fade-in { 73 | animation-name: fadeIn; 74 | } 75 | 76 | .fade-in-50 { 77 | animation-name: fadeIn; 78 | animation-duration: 0.3s; 79 | } 80 | 81 | .slide-in-from-bottom { 82 | animation-name: slideUp; 83 | } 84 | 85 | .scale-in { 86 | animation-name: scaleIn; 87 | } 88 | 89 | /* Hover effects */ 90 | .hover-lift { 91 | @apply transition-transform duration-200 hover:-translate-y-0.5; 92 | } 93 | 94 | .hover-glow { 95 | @apply transition-shadow duration-200 hover:shadow-lg; 96 | } 97 | 98 | /* Custom scrollbar */ 99 | .scrollbar-thin { 100 | scrollbar-width: thin; 101 | scrollbar-color: theme('colors.gray.400') transparent; 102 | } 103 | 104 | .scrollbar-thin::-webkit-scrollbar { 105 | width: 8px; 106 | height: 8px; 107 | } 108 | 109 | .scrollbar-thin::-webkit-scrollbar-track { 110 | background: transparent; 111 | } 112 | 113 | .scrollbar-thin::-webkit-scrollbar-thumb { 114 | @apply bg-gray-400 rounded-full; 115 | } 116 | 117 | .scrollbar-thin::-webkit-scrollbar-thumb:hover { 118 | @apply bg-gray-500; 119 | } 120 | 121 | /* Status colors - using neutral colors */ 122 | .status-success { 123 | @apply text-foreground; 124 | } 125 | 126 | .status-warning { 127 | @apply text-muted-foreground; 128 | } 129 | 130 | .status-error { 131 | @apply text-foreground; 132 | } 133 | 134 | /* Method styles - all neutral with hover effects */ 135 | .method-get, 136 | .method-post, 137 | .method-put, 138 | .method-delete, 139 | .method-patch { 140 | @apply font-medium transition-opacity hover:opacity-70; 141 | } 142 | 143 | /* Card hover effects */ 144 | .card-hover { 145 | @apply transition-all duration-200 hover:shadow-md hover:-translate-y-0.5; 146 | } 147 | 148 | /* Table row hover */ 149 | .table-row-hover { 150 | @apply transition-colors duration-150 hover:bg-muted/50; 151 | } 152 | 153 | /* Drawer animations */ 154 | .drawer-backdrop { 155 | animation: fadeIn 0.2s ease-out; 156 | } 157 | 158 | .drawer-content { 159 | animation: slideUp 0.3s ease-out; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /docs/middleware-tracing.md: -------------------------------------------------------------------------------- 1 | # Middleware Tracing 2 | 3 | GoVisual provides in-depth tracing of middleware execution in your HTTP request handling pipeline. This feature helps you visualize the flow of requests through your middleware stack and identify performance bottlenecks. 4 | 5 | ## How Middleware Tracing Works 6 | 7 | When a request is processed through a GoVisual-wrapped handler, the middleware tracer: 8 | 9 | 1. Records when each middleware function starts and ends 10 | 2. Captures the time spent in each middleware component 11 | 3. Builds a hierarchical representation of the middleware stack 12 | 4. Visualizes this data in the dashboard 13 | 14 | ## Viewing Middleware Traces 15 | 16 | In the GoVisual dashboard, click on any request to see its details, then navigate to the "Middleware Trace" tab. The trace is displayed as a hierarchical tree with: 17 | 18 | - Middleware name/type 19 | - Execution time (absolute and relative) 20 | - Execution order 21 | - Parent-child relationships 22 | 23 | ## Supported Middleware 24 | 25 | GoVisual can trace any standard Go HTTP middleware that follows the common middleware chaining patterns: 26 | 27 | ### Standard HTTP Middleware 28 | 29 | ```go 30 | func MyMiddleware(next http.Handler) http.Handler { 31 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | // Pre-processing 33 | next.ServeHTTP(w, r) 34 | // Post-processing 35 | }) 36 | } 37 | ``` 38 | 39 | ### Middleware Using Context 40 | 41 | ```go 42 | func ContextMiddleware(next http.Handler) http.Handler { 43 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 | // Add something to context 45 | ctx := context.WithValue(r.Context(), "key", "value") 46 | next.ServeHTTP(w, r.WithContext(ctx)) 47 | }) 48 | } 49 | ``` 50 | 51 | ### Function Adapters 52 | 53 | ```go 54 | func LoggingAdapter(h http.HandlerFunc) http.HandlerFunc { 55 | return func(w http.ResponseWriter, r *http.Request) { 56 | // Log request 57 | h(w, r) 58 | // Log response 59 | } 60 | } 61 | ``` 62 | 63 | ## Middleware Trace Format 64 | 65 | Internally, middleware traces are stored with this structure: 66 | 67 | ```go 68 | type MiddlewareTraceEntry struct { 69 | Name string // Name of the middleware 70 | StartTime time.Time // When middleware execution started 71 | EndTime time.Time // When middleware execution ended 72 | Duration time.Duration // Time spent in this middleware 73 | Depth int // Nesting level in the middleware stack 74 | Parent int // Index of parent middleware (-1 for root) 75 | Children []int // Indices of child middleware entries 76 | Metadata map[string]string // Additional metadata 77 | } 78 | ``` 79 | 80 | ## Performance Considerations 81 | 82 | Middleware tracing adds minimal overhead to request processing: 83 | 84 | - Typically less than 0.1ms per middleware layer 85 | - Trace data is collected only for successful requests 86 | - Trace size scales with middleware complexity 87 | 88 | ## Example: Analyzing Middleware Performance 89 | 90 | Here's a common scenario where middleware tracing helps: 91 | 92 | 1. You notice certain API requests are slower than expected 93 | 2. In the GoVisual dashboard, you find these requests in the table 94 | 3. You open the middleware trace and see one middleware taking significantly longer than others 95 | 4. You optimize that middleware and immediately see the performance improvement 96 | 97 | ## Relationship with OpenTelemetry 98 | 99 | When OpenTelemetry integration is enabled: 100 | 101 | - Middleware traces are exported as OpenTelemetry spans 102 | - Each middleware becomes a span in the trace 103 | - The hierarchy is preserved in the span relationship 104 | - You can view the same data in your OpenTelemetry backend (Jaeger, Zipkin, etc.) 105 | 106 | ## Related Documentation 107 | 108 | - [OpenTelemetry Integration](opentelemetry.md) - Exporting middleware traces to OpenTelemetry 109 | - [Dashboard](dashboard.md) - How to use the dashboard to view middleware traces 110 | - [Configuration Options](configuration.md) - Available configuration options 111 | -------------------------------------------------------------------------------- /docs/opentelemetry.md: -------------------------------------------------------------------------------- 1 | # Using GoVisual with OpenTelemetry 2 | 3 | GoVisual includes OpenTelemetry integration, allowing you to export telemetry data to your preferred backend. 4 | 5 | ## Prerequisites 6 | 7 | To use OpenTelemetry with GoVisual, you need: 8 | 9 | 1. An OTLP-compatible collector running (such as Jaeger with OTLP enabled) 10 | 2. GoVisual v1.0.0 or later 11 | 12 | ## Quick Start 13 | 14 | ### 1. Start an OpenTelemetry Backend 15 | 16 | The easiest way to get started is with Jaeger. Run it using Docker: 17 | 18 | ```bash 19 | docker run -d --name jaeger \ 20 | -e COLLECTOR_OTLP_ENABLED=true \ 21 | -p 16686:16686 \ 22 | -p 4317:4317 \ 23 | -p 4318:4318 \ 24 | jaegertracing/all-in-one:latest 25 | ``` 26 | 27 | ### 2. Enable OpenTelemetry in your Go application 28 | 29 | ```go 30 | package main 31 | 32 | import ( 33 | "net/http" 34 | "github.com/doganarif/govisual" 35 | ) 36 | 37 | func main() { 38 | mux := http.NewServeMux() 39 | 40 | // Add your routes 41 | mux.HandleFunc("/api/users", userHandler) 42 | 43 | // Enable GoVisual with OpenTelemetry 44 | handler := govisual.Wrap( 45 | mux, 46 | govisual.WithOpenTelemetry(true), // Enable OpenTelemetry 47 | govisual.WithServiceName("my-service"), // Set service name 48 | govisual.WithServiceVersion("1.0.0"), // Set service version 49 | govisual.WithOTelEndpoint("localhost:4317"), // OTLP exporter endpoint 50 | govisual.WithRequestBodyLogging(true), // Log request bodies 51 | govisual.WithResponseBodyLogging(true), // Log response bodies 52 | ) 53 | 54 | http.ListenAndServe(":8080", handler) 55 | } 56 | ``` 57 | 58 | ### 3. Run the example 59 | 60 | You can run the included example in the repository: 61 | 62 | ```bash 63 | cd cmd/examples/otel 64 | docker-compose up -d 65 | go run main.go 66 | ``` 67 | 68 | Then visit: 69 | 70 | - GoVisual dashboard: http://localhost:8080/\_\_viz 71 | - Jaeger UI: http://localhost:16686 72 | 73 | ## Configuration Options 74 | 75 | | Option | Description | Default | 76 | | ---------------------------- | -------------------------------------- | ------------------ | 77 | | `WithOpenTelemetry(bool)` | Enable OpenTelemetry integration | `false` | 78 | | `WithServiceName(string)` | Set the service name for OpenTelemetry | `"govisual"` | 79 | | `WithServiceVersion(string)` | Set the service version | `"dev"` | 80 | | `WithOTelEndpoint(string)` | Set the OTLP exporter endpoint | `"localhost:4317"` | 81 | 82 | ## How It Works 83 | 84 | When OpenTelemetry is enabled: 85 | 86 | 1. GoVisual initializes an OpenTelemetry tracer with the provided service name and version 87 | 2. HTTP requests passing through GoVisual are automatically traced 88 | 3. Trace data is exported to the configured OTLP endpoint 89 | 4. The original GoVisual dashboard continues to work alongside OpenTelemetry 90 | 91 | ## Adding Custom Spans 92 | 93 | You can add custom spans within your request handlers: 94 | 95 | ```go 96 | func myHandler(w http.ResponseWriter, r *http.Request) { 97 | // Get the context from the request (it contains the parent span from GoVisual) 98 | ctx := r.Context() 99 | 100 | // Start a new child span 101 | ctx, span := otel.Tracer("my-service").Start(ctx, "my-operation") 102 | defer span.End() 103 | 104 | // Add attributes to the span 105 | span.SetAttributes(attribute.String("user.id", "123")) 106 | 107 | // Create nested spans for detailed operations 108 | _, dbSpan := otel.Tracer("my-service").Start(ctx, "database.query") 109 | // ... do database work 110 | dbSpan.End() 111 | 112 | // Respond to the client 113 | w.Write([]byte("Hello, world!")) 114 | } 115 | ``` 116 | 117 | ## Troubleshooting 118 | 119 | If you encounter issues: 120 | 121 | 1. Check that your OpenTelemetry collector is running and accessible 122 | 2. Ensure the endpoint in `WithOTelEndpoint()` matches your collector configuration 123 | 3. Look for initialization errors in your application logs 124 | 125 | To see a full working example, refer to the `cmd/examples/otel` directory in the repository. 126 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/app-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { 3 | Sidebar, 4 | SidebarContent, 5 | SidebarFooter, 6 | SidebarGroup, 7 | SidebarGroupContent, 8 | SidebarGroupLabel, 9 | SidebarHeader, 10 | SidebarMenu, 11 | SidebarMenuButton, 12 | SidebarMenuItem, 13 | } from "@/components/ui/sidebar"; 14 | import { Button } from "@/components/ui/button"; 15 | 16 | interface AppSidebarProps { 17 | activeTab: string; 18 | onTabChange: (tab: string) => void; 19 | stats: { 20 | total: number; 21 | successRate: number; 22 | avgDuration: number; 23 | }; 24 | onClearAll: () => void; 25 | } 26 | 27 | const menuItems = [ 28 | { id: "dashboard", label: "Dashboard" }, 29 | { id: "requests", label: "Requests" }, 30 | { id: "environment", label: "Environment" }, 31 | { id: "trace", label: "Trace" }, 32 | ]; 33 | 34 | export function AppSidebar({ 35 | activeTab, 36 | onTabChange, 37 | stats, 38 | onClearAll, 39 | }: AppSidebarProps) { 40 | return ( 41 | 42 | 43 |

GoVisual

44 |

HTTP Request Visualizer

45 |
46 | 47 | 48 | 49 | Navigation 50 | 51 | 52 | {menuItems.map((item) => ( 53 | 54 | onTabChange(item.id)} 56 | isActive={activeTab === item.id} 57 | className="w-full" 58 | > 59 | {item.label} 60 | 61 | 62 | ))} 63 | 64 | 65 | 66 | 67 | 68 | Quick Stats 69 | 70 |
71 |
72 | Total 73 | 74 | {stats.total} 75 | 76 |
77 |
78 | Success 79 | 80 | {stats.successRate}% 81 | 82 |
83 |
84 | Avg 85 | 86 | {stats.avgDuration}ms 87 | 88 |
89 |
90 |
91 |
92 |
93 | 94 | 95 | 98 |
99 |

100 | VERSION 0.2.0 101 |

102 |

103 | Created by{" "} 104 | 110 | @doganarif 111 | 112 |

113 |
114 |
115 |
116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /cmd/examples/otel/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/doganarif/govisual" 12 | "go.opentelemetry.io/otel" 13 | "go.opentelemetry.io/otel/attribute" 14 | "go.opentelemetry.io/otel/trace" 15 | ) 16 | 17 | func main() { 18 | var ( 19 | port int 20 | enableOTel bool 21 | ) 22 | flag.IntVar(&port, "port", 8080, "HTTP server port") 23 | flag.BoolVar(&enableOTel, "otel", true, "Enable OpenTelemetry") 24 | flag.Parse() 25 | 26 | // Create HTTP mux 27 | mux := http.NewServeMux() 28 | 29 | // Add routes 30 | mux.HandleFunc("/", homeHandler) 31 | mux.HandleFunc("/api/users", usersHandler) 32 | mux.HandleFunc("/api/search", searchHandler) 33 | mux.HandleFunc("/api/health", healthHandler) 34 | 35 | // Configure GoVisual options 36 | options := []govisual.Option{ 37 | govisual.WithRequestBodyLogging(true), 38 | govisual.WithResponseBodyLogging(true), 39 | govisual.WithIgnorePaths("/api/health"), 40 | } 41 | 42 | // Add OpenTelemetry options if enabled 43 | if enableOTel { 44 | options = append(options, 45 | govisual.WithOpenTelemetry(true), 46 | govisual.WithServiceName("govisual-otel-example"), 47 | govisual.WithServiceVersion("1.0.0"), 48 | govisual.WithOTelEndpoint("localhost:4317"), 49 | ) 50 | log.Println("🔭 OpenTelemetry enabled!") 51 | } 52 | 53 | // Wrap with GoVisual 54 | handler := govisual.Wrap(mux, options...) 55 | log.Printf("🔍 Request visualizer enabled at http://localhost:%d/__viz", port) 56 | 57 | // Start the server 58 | addr := fmt.Sprintf(":%d", port) 59 | log.Printf("Server started at http://localhost%s", addr) 60 | log.Fatal(http.ListenAndServe(addr, handler)) 61 | } 62 | 63 | func homeHandler(w http.ResponseWriter, r *http.Request) { 64 | if r.URL.Path != "/" { 65 | http.NotFound(w, r) 66 | return 67 | } 68 | 69 | w.Header().Set("Content-Type", "text/html") 70 | fmt.Fprintf(w, ` 71 |

GoVisual OpenTelemetry Example

72 |

Visit /__viz to access the request visualizer

73 |

Visit Jaeger UI to see traces

74 |

API Endpoints:

75 | 80 | `) 81 | } 82 | 83 | func usersHandler(w http.ResponseWriter, r *http.Request) { 84 | ctx := r.Context() 85 | ctx, span := otel.Tracer("example").Start(ctx, "usersHandler", 86 | trace.WithAttributes(attribute.String("handler", "users"))) 87 | defer span.End() 88 | 89 | // Simulate processing 90 | time.Sleep(100 * time.Millisecond) 91 | 92 | // Create child span without using the context 93 | _, childSpan := otel.Tracer("example").Start(ctx, "database.query") 94 | time.Sleep(150 * time.Millisecond) 95 | childSpan.End() 96 | 97 | // Response 98 | w.Header().Set("Content-Type", "application/json") 99 | json.NewEncoder(w).Encode([]map[string]interface{}{ 100 | {"id": 1, "name": "John Doe"}, 101 | {"id": 2, "name": "Jane Smith"}, 102 | }) 103 | } 104 | 105 | func searchHandler(w http.ResponseWriter, r *http.Request) { 106 | _, span := otel.Tracer("example").Start(r.Context(), "searchHandler") 107 | defer span.End() 108 | 109 | // Get query parameter 110 | query := r.URL.Query().Get("q") 111 | if query == "" { 112 | http.Error(w, "Missing search query", http.StatusBadRequest) 113 | return 114 | } 115 | 116 | // Add attribute to span 117 | span.SetAttributes(attribute.String("search.query", query)) 118 | 119 | // Simulate search 120 | time.Sleep(200 * time.Millisecond) 121 | 122 | // Response 123 | w.Header().Set("Content-Type", "application/json") 124 | json.NewEncoder(w).Encode(map[string]interface{}{ 125 | "query": query, 126 | "results": []map[string]string{ 127 | {"name": "Result 1"}, 128 | {"name": "Result 2"}, 129 | }, 130 | }) 131 | } 132 | 133 | func healthHandler(w http.ResponseWriter, r *http.Request) { 134 | w.Header().Set("Content-Type", "application/json") 135 | json.NewEncoder(w).Encode(map[string]string{ 136 | "status": "healthy", 137 | }) 138 | } 139 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/SimpleSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { h, ComponentChildren } from "preact"; 2 | import { cn } from "@/lib/utils"; 3 | import { Button } from "@/components/ui/button"; 4 | 5 | interface SimpleSidebarProps { 6 | activeTab: string; 7 | onTabChange: (tab: string) => void; 8 | stats: { 9 | total: number; 10 | successRate: number; 11 | avgDuration: number; 12 | }; 13 | onClearAll: () => void; 14 | } 15 | 16 | const menuItems = [ 17 | { id: "dashboard", label: "Dashboard" }, 18 | { id: "requests", label: "Requests" }, 19 | { id: "analytics", label: "Analytics" }, 20 | { id: "environment", label: "Environment" }, 21 | { id: "trace", label: "Trace" }, 22 | ]; 23 | 24 | export function SimpleSidebar({ 25 | activeTab, 26 | onTabChange, 27 | stats, 28 | onClearAll, 29 | }: SimpleSidebarProps) { 30 | return ( 31 | 113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /docs/request-logging.md: -------------------------------------------------------------------------------- 1 | # Request Logging 2 | 3 | GoVisual can capture and log HTTP requests and responses passing through your application. This document explains how request logging works and how to configure it. 4 | 5 | ## Basic Logging 6 | 7 | By default, GoVisual logs basic request and response metadata: 8 | 9 | - HTTP method (GET, POST, PUT, etc.) 10 | - URL path 11 | - Query parameters 12 | - Status code 13 | - Response time 14 | - Timestamp 15 | - Request and response headers 16 | 17 | This basic information is always captured and does not require any special configuration. 18 | 19 | ## Body Logging 20 | 21 | For more detailed logging, you can enable request and response body logging: 22 | 23 | ```go 24 | handler := govisual.Wrap( 25 | mux, 26 | govisual.WithRequestBodyLogging(true), // Log request bodies 27 | govisual.WithResponseBodyLogging(true), // Log response bodies 28 | ) 29 | ``` 30 | 31 | ### Important Considerations 32 | 33 | When enabling body logging, keep in mind: 34 | 35 | 1. **Performance Impact**: Logging bodies requires reading them completely into memory, which may impact performance for large payloads 36 | 2. **Security Concerns**: Request and response bodies may contain sensitive information (passwords, tokens, PII) 37 | 3. **Memory Usage**: Bodies are stored in memory by default, which can increase memory usage 38 | 39 | ## Ignoring Paths 40 | 41 | To prevent logging of certain paths (like health checks or static assets), use the `WithIgnorePaths` option: 42 | 43 | ```go 44 | handler := govisual.Wrap( 45 | mux, 46 | govisual.WithIgnorePaths( 47 | "/health", // Exact match 48 | "/metrics", // Exact match 49 | "/static/*", // Wildcard pattern 50 | "/api/auth/*" // Wildcard pattern 51 | ), 52 | ) 53 | ``` 54 | 55 | The dashboard path (`/__viz` by default) is automatically ignored to prevent recursive logging. 56 | 57 | ## Storage Considerations 58 | 59 | How requests are stored depends on your configured storage backend: 60 | 61 | - **Memory Storage**: Logs are kept in memory and lost when the application restarts 62 | - **PostgreSQL Storage**: Logs are stored in a database table and persist across restarts 63 | - **Redis Storage**: Logs are stored with a configurable time-to-live (TTL) 64 | 65 | See [Storage Backends](storage-backends.md) for more details. 66 | 67 | ## Custom Headers 68 | 69 | All headers are logged by default. If some headers contain sensitive information, you should handle them at the application level before they reach GoVisual. 70 | 71 | ## Request Log Format 72 | 73 | Internally, GoVisual stores request logs with the following structure: 74 | 75 | ```go 76 | type RequestLog struct { 77 | ID string // Unique identifier 78 | Timestamp time.Time // When the request was received 79 | Method string // HTTP method 80 | Path string // URL path 81 | Query string // Query parameters 82 | RequestHeaders map[string][]string // Request headers 83 | ResponseHeaders map[string][]string // Response headers 84 | StatusCode int // HTTP status code 85 | Duration time.Duration // Response time 86 | RequestBody string // Request body (if enabled) 87 | ResponseBody string // Response body (if enabled) 88 | Error string // Error message (if any) 89 | MiddlewareTrace []MiddlewareTraceEntry // Middleware execution trace 90 | RouteTrace []RouteTraceEntry // Route matching trace 91 | } 92 | ``` 93 | 94 | ## Example 95 | 96 | A complete example of request logging configuration: 97 | 98 | ```go 99 | handler := govisual.Wrap( 100 | mux, 101 | govisual.WithRequestBodyLogging(true), 102 | govisual.WithResponseBodyLogging(true), 103 | govisual.WithIgnorePaths("/health", "/metrics", "/static/*"), 104 | govisual.WithMaxRequests(1000), 105 | ) 106 | ``` 107 | 108 | ## Related Documentation 109 | 110 | - [Configuration Options](configuration.md) - All available configuration options 111 | - [Storage Backends](storage-backends.md) - Configure where logs are stored 112 | - [Middleware Tracing](middleware-tracing.md) - How middleware tracing works 113 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { Button } from "./ui/button"; 3 | import { cn } from "../lib/utils"; 4 | 5 | interface SidebarProps { 6 | activeTab: string; 7 | onTabChange: (tab: string) => void; 8 | stats: { 9 | total: number; 10 | successRate: number; 11 | avgDuration: number; 12 | }; 13 | onClearAll: () => void; 14 | } 15 | 16 | export function Sidebar({ 17 | activeTab, 18 | onTabChange, 19 | stats, 20 | onClearAll, 21 | }: SidebarProps) { 22 | const tabs = [ 23 | { id: "dashboard", label: "Dashboard" }, 24 | { id: "requests", label: "Requests" }, 25 | { id: "environment", label: "Environment" }, 26 | { id: "trace", label: "Trace" }, 27 | ]; 28 | 29 | return ( 30 | 117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/FlameGraph.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { useEffect, useRef } from "preact/hooks"; 3 | import * as d3 from "d3"; 4 | import { FlameGraphNode } from "../lib/api"; 5 | 6 | interface FlameGraphProps { 7 | data: FlameGraphNode | null; 8 | width?: number; 9 | height?: number; 10 | } 11 | 12 | export function FlameGraph({ 13 | data, 14 | width = 900, 15 | height = 400, 16 | }: FlameGraphProps) { 17 | const svgRef = useRef(null); 18 | const tooltipRef = useRef(null); 19 | 20 | useEffect(() => { 21 | if (!data || !svgRef.current) return; 22 | 23 | // Clear previous content 24 | const svg = d3.select(svgRef.current); 25 | svg.selectAll("*").remove(); 26 | 27 | // Set up dimensions 28 | const cellHeight = 20; 29 | const actualHeight = height || 400; 30 | 31 | // Create hierarchy 32 | const root = d3 33 | .hierarchy(data) 34 | .sum((d: any) => d.value || 0) 35 | .sort((a, b) => (b.value || 0) - (a.value || 0)); 36 | 37 | // Create partition layout 38 | const partition = d3 39 | .partition() 40 | .size([width, actualHeight]) 41 | .padding(1) 42 | .round(true); 43 | 44 | partition(root); 45 | 46 | // Color scale 47 | const color = d3.scaleOrdinal(d3.schemeTableau10); 48 | 49 | // Create groups for each node 50 | const g = svg 51 | .selectAll("g") 52 | .data(root.descendants()) 53 | .join("g") 54 | .attr("transform", (d) => `translate(${d.x0},${d.depth * cellHeight})`); 55 | 56 | // Add rectangles 57 | g.append("rect") 58 | .attr("x", 0) 59 | .attr("width", (d) => Math.max(0, d.x1 - d.x0)) 60 | .attr("height", cellHeight - 1) 61 | .attr("fill", (d) => { 62 | if (!d.depth) return "#f3f4f6"; 63 | return color(d.data.name); 64 | }) 65 | .style("stroke", "#fff") 66 | .style("cursor", "pointer") 67 | .on("mouseover", function (event, d) { 68 | if (tooltipRef.current) { 69 | const percentage = ( 70 | ((d.value || 0) / (root.value || 1)) * 71 | 100 72 | ).toFixed(2); 73 | tooltipRef.current.innerHTML = ` 74 |
${d.data.name}
75 |
${percentage}% of total
76 |
Value: ${d.value}
77 | `; 78 | tooltipRef.current.style.display = "block"; 79 | tooltipRef.current.style.left = event.pageX + 10 + "px"; 80 | tooltipRef.current.style.top = event.pageY - 28 + "px"; 81 | } 82 | }) 83 | .on("mousemove", function (event) { 84 | if (tooltipRef.current) { 85 | tooltipRef.current.style.left = event.pageX + 10 + "px"; 86 | tooltipRef.current.style.top = event.pageY - 28 + "px"; 87 | } 88 | }) 89 | .on("mouseout", function () { 90 | if (tooltipRef.current) { 91 | tooltipRef.current.style.display = "none"; 92 | } 93 | }); 94 | 95 | // Add text labels 96 | g.append("text") 97 | .attr("x", 4) 98 | .attr("y", cellHeight / 2) 99 | .attr("dy", "0.32em") 100 | .text((d) => { 101 | const width = d.x1 - d.x0; 102 | if (width < 30) return ""; 103 | const name = d.data.name; 104 | const maxChars = Math.floor(width / 7); 105 | return name.length > maxChars 106 | ? name.substring(0, maxChars - 1) + "…" 107 | : name; 108 | }) 109 | .style("pointer-events", "none") 110 | .style("fill", (d) => (!d.depth ? "#000" : "#fff")) 111 | .style("font-size", "12px") 112 | .style("font-family", "monospace"); 113 | }, [data, width, height]); 114 | 115 | if (!data) { 116 | return ( 117 |
118 | No flame graph data available 119 |
120 | ); 121 | } 122 | 123 | return ( 124 |
125 | 132 |
142 |
143 | ); 144 | } 145 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/ui/sheet.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SheetPrimitive from "@radix-ui/react-dialog" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | import { X } from "lucide-react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Sheet = SheetPrimitive.Root 9 | 10 | const SheetTrigger = SheetPrimitive.Trigger 11 | 12 | const SheetClose = SheetPrimitive.Close 13 | 14 | const SheetPortal = SheetPrimitive.Portal 15 | 16 | const SheetOverlay = React.forwardRef< 17 | React.ElementRef, 18 | React.ComponentPropsWithoutRef 19 | >(({ className, ...props }, ref) => ( 20 | 28 | )) 29 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName 30 | 31 | const sheetVariants = cva( 32 | "fixed z-50 gap-4 bg-white p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 dark:bg-slate-950", 33 | { 34 | variants: { 35 | side: { 36 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", 37 | bottom: 38 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", 39 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", 40 | right: 41 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", 42 | }, 43 | }, 44 | defaultVariants: { 45 | side: "right", 46 | }, 47 | } 48 | ) 49 | 50 | interface SheetContentProps 51 | extends React.ComponentPropsWithoutRef, 52 | VariantProps {} 53 | 54 | const SheetContent = React.forwardRef< 55 | React.ElementRef, 56 | SheetContentProps 57 | >(({ side = "right", className, children, ...props }, ref) => ( 58 | 59 | 60 | 65 | {children} 66 | 67 | 68 | Close 69 | 70 | 71 | 72 | )) 73 | SheetContent.displayName = SheetPrimitive.Content.displayName 74 | 75 | const SheetHeader = ({ 76 | className, 77 | ...props 78 | }: React.HTMLAttributes) => ( 79 |
86 | ) 87 | SheetHeader.displayName = "SheetHeader" 88 | 89 | const SheetFooter = ({ 90 | className, 91 | ...props 92 | }: React.HTMLAttributes) => ( 93 |
100 | ) 101 | SheetFooter.displayName = "SheetFooter" 102 | 103 | const SheetTitle = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )) 113 | SheetTitle.displayName = SheetPrimitive.Title.displayName 114 | 115 | const SheetDescription = React.forwardRef< 116 | React.ElementRef, 117 | React.ComponentPropsWithoutRef 118 | >(({ className, ...props }, ref) => ( 119 | 124 | )) 125 | SheetDescription.displayName = SheetPrimitive.Description.displayName 126 | 127 | export { 128 | Sheet, 129 | SheetPortal, 130 | SheetOverlay, 131 | SheetTrigger, 132 | SheetClose, 133 | SheetContent, 134 | SheetHeader, 135 | SheetFooter, 136 | SheetTitle, 137 | SheetDescription, 138 | } 139 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/Filters.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; 3 | import { Button } from "./ui/button"; 4 | 5 | interface FiltersProps { 6 | onFilterChange: (filters: FilterState) => void; 7 | onClear: () => void; 8 | } 9 | 10 | export interface FilterState { 11 | method: string; 12 | statusCode: string; 13 | path: string; 14 | minDuration: string; 15 | } 16 | 17 | export function Filters({ onFilterChange, onClear }: FiltersProps) { 18 | const handleFilterChange = () => { 19 | const filters: FilterState = { 20 | method: (document.getElementById("method-filter") as HTMLSelectElement)?.value || "", 21 | statusCode: (document.getElementById("status-filter") as HTMLSelectElement)?.value || "", 22 | path: (document.getElementById("path-filter") as HTMLInputElement)?.value || "", 23 | minDuration: (document.getElementById("duration-filter") as HTMLInputElement)?.value || "" 24 | }; 25 | onFilterChange(filters); 26 | }; 27 | 28 | const handleReset = () => { 29 | (document.getElementById("method-filter") as HTMLSelectElement).value = ""; 30 | (document.getElementById("status-filter") as HTMLSelectElement).value = ""; 31 | (document.getElementById("path-filter") as HTMLInputElement).value = ""; 32 | (document.getElementById("duration-filter") as HTMLInputElement).value = ""; 33 | onFilterChange({ 34 | method: "", 35 | statusCode: "", 36 | path: "", 37 | minDuration: "" 38 | }); 39 | }; 40 | 41 | return ( 42 | 43 | 44 | Filters 45 | 46 | 47 |
48 | {/* HTTP Method */} 49 |
50 | 53 | 67 |
68 | 69 | {/* Status Code */} 70 |
71 | 74 | 85 |
86 | 87 | {/* Path Contains */} 88 |
89 | 92 | 99 |
100 | 101 | {/* Min Duration */} 102 |
103 | 106 | 113 |
114 |
115 | 116 |
117 | 118 | 119 | 120 |
121 |
122 |
123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /wrap.go: -------------------------------------------------------------------------------- 1 | package govisual 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "strings" 10 | "sync" 11 | "syscall" 12 | 13 | "github.com/doganarif/govisual/internal/dashboard" 14 | "github.com/doganarif/govisual/internal/middleware" 15 | "github.com/doganarif/govisual/internal/profiling" 16 | "github.com/doganarif/govisual/internal/store" 17 | "github.com/doganarif/govisual/internal/telemetry" 18 | ) 19 | 20 | var ( 21 | // Global signal handler to ensure we only have one 22 | signalOnce sync.Once 23 | shutdownFuncs []func(context.Context) error 24 | shutdownMutex sync.Mutex 25 | ) 26 | 27 | // addShutdownFunc adds a shutdown function to be called on signal 28 | func addShutdownFunc(fn func(context.Context) error) { 29 | if fn == nil { 30 | log.Println("Warning: Attempted to register nil shutdown function, ignoring") 31 | return 32 | } 33 | shutdownMutex.Lock() 34 | defer shutdownMutex.Unlock() 35 | shutdownFuncs = append(shutdownFuncs, fn) 36 | } 37 | 38 | // setupSignalHandler sets up a single signal handler for all cleanup operations 39 | func setupSignalHandler() { 40 | signalOnce.Do(func() { 41 | signals := make(chan os.Signal, 1) 42 | signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) 43 | 44 | go func() { 45 | sig := <-signals 46 | log.Printf("Received shutdown signal (%v), cleaning up...", sig) 47 | 48 | ctx := context.Background() 49 | shutdownMutex.Lock() 50 | funcs := make([]func(context.Context) error, len(shutdownFuncs)) 51 | copy(funcs, shutdownFuncs) 52 | shutdownMutex.Unlock() 53 | 54 | // Execute all shutdown functions 55 | for _, fn := range funcs { 56 | if err := fn(ctx); err != nil { 57 | log.Printf("Error during shutdown: %v", err) 58 | } 59 | } 60 | 61 | log.Println("Cleanup completed, exiting...") 62 | 63 | // Stop listening for more signals and exit 64 | signal.Stop(signals) 65 | os.Exit(0) 66 | }() 67 | }) 68 | } 69 | 70 | // Wrap wraps an http.Handler with request visualization middleware 71 | func Wrap(handler http.Handler, opts ...Option) http.Handler { 72 | // Apply options to default config 73 | config := defaultConfig() 74 | for _, opt := range opts { 75 | opt(config) 76 | } 77 | 78 | // Create store based on configuration 79 | var requestStore store.Store 80 | var err error 81 | 82 | storeConfig := &store.StorageConfig{ 83 | Type: config.StorageType, 84 | Capacity: config.MaxRequests, 85 | ConnectionString: config.ConnectionString, 86 | TableName: config.TableName, 87 | TTL: config.RedisTTL, 88 | ExistingDB: config.ExistingDB, 89 | } 90 | 91 | requestStore, err = store.NewStore(storeConfig) 92 | if err != nil { 93 | log.Printf("Failed to create configured storage backend: %v. Falling back to in-memory storage.", err) 94 | requestStore = store.NewInMemoryStore(config.MaxRequests) 95 | } 96 | 97 | // Add store cleanup to shutdown functions 98 | addShutdownFunc(func(ctx context.Context) error { 99 | if err := requestStore.Close(); err != nil { 100 | log.Printf("Error closing storage: %v", err) 101 | return err 102 | } 103 | return nil 104 | }) 105 | 106 | // Create profiler if enabled 107 | var profiler *profiling.Profiler 108 | if config.EnableProfiling { 109 | profiler = profiling.NewProfiler(config.MaxProfileMetrics) 110 | profiler.SetEnabled(config.EnableProfiling) 111 | profiler.SetProfileType(config.ProfileType) 112 | profiler.SetThreshold(config.ProfileThreshold) 113 | log.Printf("Performance profiling enabled with threshold: %v", config.ProfileThreshold) 114 | } 115 | 116 | // Create middleware wrapper with profiling support 117 | var wrapped http.Handler 118 | if profiler != nil { 119 | wrapped = middleware.WrapWithProfiling(handler, requestStore, config.LogRequestBody, config.LogResponseBody, config, profiler) 120 | } else { 121 | wrapped = middleware.Wrap(handler, requestStore, config.LogRequestBody, config.LogResponseBody, config) 122 | } 123 | 124 | // Initialize OpenTelemetry if enabled 125 | if config.EnableOpenTelemetry { 126 | ctx := context.Background() 127 | shutdown, err := telemetry.InitTracer(ctx, config.ServiceName, config.ServiceVersion, config.OTelEndpoint) 128 | if err != nil { 129 | log.Printf("Failed to initialize OpenTelemetry: %v", err) 130 | } else { 131 | log.Printf("OpenTelemetry initialized with service name: %s, endpoint: %s", config.ServiceName, config.OTelEndpoint) 132 | 133 | // Add OpenTelemetry shutdown to shutdown functions 134 | addShutdownFunc(shutdown) 135 | 136 | // Wrap with OpenTelemetry middleware 137 | wrapped = middleware.NewOTelMiddleware(wrapped, config.ServiceName, config.ServiceVersion) 138 | } 139 | } 140 | 141 | // Set up the single signal handler 142 | setupSignalHandler() 143 | 144 | // Create dashboard handler with profiler 145 | dashHandler := dashboard.NewHandler(requestStore, profiler) 146 | 147 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 148 | if strings.HasPrefix(r.URL.Path, config.DashboardPath) { 149 | // Handle the dashboard routes 150 | http.StripPrefix(config.DashboardPath, dashHandler).ServeHTTP(w, r) 151 | return 152 | } 153 | 154 | // Otherwise, serve the application 155 | wrapped.ServeHTTP(w, r) 156 | }) 157 | } 158 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | This guide covers common issues and their solutions when working with GoVisual. 4 | 5 | ## Dashboard Not Accessible 6 | 7 | ### Symptom 8 | 9 | You can't access the GoVisual dashboard at the expected URL path. 10 | 11 | ### Possible Causes and Solutions 12 | 13 | 1. **Wrong Dashboard Path** 14 | 15 | - Confirm the dashboard path in your configuration 16 | - Default is `/__viz`, but you may have changed it with `WithDashboardPath` 17 | - Example: `http://localhost:8080/__viz` 18 | 19 | 2. **Application Not Running** 20 | 21 | - Verify your application is running 22 | - Check for any startup errors in logs 23 | 24 | 3. **Path Conflict** 25 | 26 | - Your application might have a route that conflicts with the dashboard path 27 | - Change the dashboard path to avoid conflicts: 28 | ```go 29 | handler := govisual.Wrap(mux, govisual.WithDashboardPath("/__govisual")) 30 | ``` 31 | 32 | 4. **Middleware Not Applied** 33 | - Make sure the GoVisual middleware is correctly applied 34 | - The handler returned by `govisual.Wrap()` must be the one used by your server 35 | 36 | ## No Requests Showing in Dashboard 37 | 38 | ### Symptom 39 | 40 | The dashboard is accessible, but no requests are displayed. 41 | 42 | ### Possible Causes and Solutions 43 | 44 | 1. **Ignored Paths** 45 | 46 | - Check if the paths you're accessing are in the ignored list 47 | - Default ignored path is the dashboard path itself 48 | 49 | 2. **Wrong Handler Chain** 50 | 51 | - Ensure that requests are passing through the GoVisual middleware 52 | - Common mistake: wrapping a handler that isn't used by your server 53 | 54 | 3. **Storage Issues** 55 | - If using a custom storage backend, check for connectivity issues 56 | - Verify that the storage is properly configured 57 | 58 | ## Performance Issues 59 | 60 | ### Symptom 61 | 62 | Application becomes slow after integrating GoVisual. 63 | 64 | ### Possible Causes and Solutions 65 | 66 | 1. **Request Body Logging** 67 | 68 | - Disable request body logging for large payloads: 69 | ```go 70 | govisual.WithRequestBodyLogging(false) 71 | ``` 72 | 73 | 2. **Response Body Logging** 74 | 75 | - Disable response body logging for large responses: 76 | ```go 77 | govisual.WithResponseBodyLogging(false) 78 | ``` 79 | 80 | 3. **High Request Volume** 81 | 82 | - Reduce the number of stored requests: 83 | ```go 84 | govisual.WithMaxRequests(50) // Default is 100 85 | ``` 86 | 87 | 4. **Memory Storage Limits** 88 | - Consider using Redis or PostgreSQL for high-volume applications 89 | 90 | ## Storage Backend Issues 91 | 92 | ### PostgreSQL Issues 93 | 94 | 1. **Connection Failed** 95 | 96 | - Check connection string 97 | - Verify the database exists and is accessible 98 | - Ensure user has appropriate permissions 99 | 100 | 2. **Table Not Created** 101 | - GoVisual should create the table automatically 102 | - If it fails, create the table manually: 103 | ```sql 104 | CREATE TABLE IF NOT EXISTS govisual_requests ( 105 | id TEXT PRIMARY KEY, 106 | timestamp TIMESTAMP WITH TIME ZONE, 107 | method TEXT, 108 | path TEXT, 109 | query TEXT, 110 | request_headers JSONB, 111 | response_headers JSONB, 112 | status_code INTEGER, 113 | duration BIGINT, 114 | request_body TEXT, 115 | response_body TEXT, 116 | error TEXT, 117 | middleware_trace JSONB, 118 | route_trace JSONB, 119 | created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() 120 | ) 121 | ``` 122 | 123 | ### Redis Issues 124 | 125 | 1. **Connection Failed** 126 | 127 | - Check Redis connection string format 128 | - Verify Redis server is running 129 | - Test connection with Redis CLI 130 | 131 | 2. **Memory Issues** 132 | - Adjust TTL to clean up older entries faster 133 | - Example: `govisual.WithRedisStorage("redis://localhost:6379/0", 3600) // 1 hour TTL` 134 | 135 | ## OpenTelemetry Integration Issues 136 | 137 | 1. **Failed to Initialize OpenTelemetry** 138 | 139 | - Check logs for initialization errors 140 | - Verify the OTLP endpoint is correctly specified 141 | - Ensure the OpenTelemetry collector is running 142 | 143 | 2. **No Spans in Collector** 144 | - Verify collector configuration accepts OTLP format 145 | - For Jaeger, ensure the OTLP receiver is enabled 146 | - Try changing the endpoint format or port 147 | 148 | ## Middleware Tracing Issues 149 | 150 | 1. **Missing Middleware in Trace** 151 | 152 | - Some third-party middleware may not be properly detected 153 | - Ensure middleware follows standard Go patterns 154 | 155 | 2. **Incorrect Timing** 156 | - High system load can affect timing accuracy 157 | - Consider testing under normal load conditions 158 | 159 | ## Getting Help 160 | 161 | If the above solutions don't resolve your issue: 162 | 163 | 1. Check the [GoVisual GitHub repository](https://github.com/doganarif/govisual) for open issues 164 | 2. Create a new issue with: 165 | - Detailed description of the problem 166 | - Error messages and logs 167 | - Configuration code 168 | - Steps to reproduce 169 | - Version information (Go, GoVisual, OS) 170 | 171 | ## Related Documentation 172 | 173 | - [Configuration Options](configuration.md) - Review available configuration options 174 | - [Storage Backends](storage-backends.md) - Storage backend configuration details 175 | - [Frequently Asked Questions](faq.md) - Common questions and answers 176 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/RequestDetails.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { 3 | Card, 4 | CardContent, 5 | CardDescription, 6 | CardHeader, 7 | CardTitle, 8 | } from "./ui/card"; 9 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; 10 | import { Badge } from "./ui/badge"; 11 | import { Button } from "./ui/button"; 12 | import { RequestLog } from "../lib/api"; 13 | 14 | interface RequestDetailsProps { 15 | request: RequestLog | null; 16 | onShowPerformance?: () => void; 17 | } 18 | 19 | export function RequestDetails({ 20 | request, 21 | onShowPerformance, 22 | }: RequestDetailsProps) { 23 | if (!request) { 24 | return ( 25 | 26 | 27 | Select a request to view details 28 | 29 | 30 | ); 31 | } 32 | 33 | const formatHeaders = (headers: Record): string => { 34 | if (!headers) return "No headers"; 35 | return Object.entries(headers) 36 | .map(([key, values]) => `${key}: ${values.join(", ")}`) 37 | .join("\n"); 38 | }; 39 | 40 | const formatBody = (body?: string): string => { 41 | if (!body) return "No body"; 42 | try { 43 | const parsed = JSON.parse(body); 44 | return JSON.stringify(parsed, null, 2); 45 | } catch { 46 | return body; 47 | } 48 | }; 49 | 50 | const hasPerformanceMetrics = !!request.PerformanceMetrics; 51 | 52 | return ( 53 | 54 | 55 |
56 |
57 | Request Details 58 | 59 | {request.Method} {request.Path} •{" "} 60 | {new Date(request.Timestamp).toLocaleString()} 61 | 62 |
63 |
64 | {hasPerformanceMetrics && ( 65 | 68 | )} 69 |
70 |
71 |
72 | 73 |
74 |
75 | 78 |

{request.ID}

79 |
80 |
81 | 84 |
85 | = 200 && request.StatusCode < 300 88 | ? "default" 89 | : request.StatusCode >= 300 && request.StatusCode < 400 90 | ? "secondary" 91 | : "outline" 92 | } 93 | > 94 | {request.StatusCode} 95 | 96 | 97 | {request.Duration}ms 98 | 99 |
100 |
101 |
102 | 103 | 104 | 105 | Headers 106 | Request 107 | Response 108 | {request.Error && Error} 109 | 110 | 111 | 112 |
113 |
114 |

Request Headers

115 |
116 |                   {formatHeaders(request.RequestHeaders)}
117 |                 
118 |
119 |
120 |

Response Headers

121 |
122 |                   {formatHeaders(request.ResponseHeaders)}
123 |                 
124 |
125 |
126 |
127 | 128 | 129 |
130 |               {formatBody(request.RequestBody)}
131 |             
132 |
133 | 134 | 135 |
136 |               {formatBody(request.ResponseBody)}
137 |             
138 |
139 | 140 | {request.Error && ( 141 | 142 |
143 |

{request.Error}

144 |
145 |
146 | )} 147 |
148 |
149 |
150 | ); 151 | } 152 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/lib/api.ts: -------------------------------------------------------------------------------- 1 | export interface RequestLog { 2 | ID: string; 3 | Timestamp: string; 4 | Method: string; 5 | Path: string; 6 | Query: string; 7 | RequestHeaders: Record; 8 | ResponseHeaders: Record; 9 | StatusCode: number; 10 | Duration: number; 11 | RequestBody?: string; 12 | ResponseBody?: string; 13 | Error?: string; 14 | MiddlewareTrace?: any[]; 15 | RouteTrace?: any; 16 | PerformanceMetrics?: PerformanceMetrics; 17 | } 18 | 19 | export interface PerformanceMetrics { 20 | request_id: string; 21 | start_time: string; 22 | end_time: string; 23 | duration: number; 24 | cpu_time: number; 25 | memory_alloc: number; 26 | memory_total_alloc: number; 27 | num_goroutines: number; 28 | num_gc: number; 29 | gc_pause_total: number; 30 | function_timings?: Record; 31 | sql_queries?: SQLQuery[]; 32 | http_calls?: HTTPCall[]; 33 | bottlenecks?: Bottleneck[]; 34 | } 35 | 36 | export interface SQLQuery { 37 | query: string; 38 | duration: number; 39 | rows: number; 40 | error?: string; 41 | } 42 | 43 | export interface HTTPCall { 44 | method: string; 45 | url: string; 46 | duration: number; 47 | status: number; 48 | size: number; 49 | } 50 | 51 | export interface Bottleneck { 52 | type: string; 53 | description: string; 54 | impact: number; 55 | duration: number; 56 | suggestion: string; 57 | } 58 | 59 | export interface FlameGraphNode { 60 | name: string; 61 | value: number; 62 | percentage?: string; 63 | children?: FlameGraphNode[]; 64 | } 65 | 66 | export interface SystemInfo { 67 | goVersion: string; 68 | goos: string; 69 | goarch: string; 70 | hostname: string; 71 | cpuCores: number; 72 | memoryUsed: number; 73 | memoryTotal: number; 74 | envVars: Record; 75 | } 76 | 77 | export interface ReplayRequest { 78 | requestId: string; 79 | url: string; 80 | method: string; 81 | headers: Record; 82 | body: string; 83 | } 84 | 85 | export interface ReplayResponse { 86 | statusCode: number; 87 | headers: Record; 88 | body: string; 89 | duration: number; 90 | originalRequest: string; 91 | } 92 | 93 | class API { 94 | private baseURL = "/__viz/api"; 95 | 96 | async getRequests(): Promise { 97 | const response = await fetch(`${this.baseURL}/requests`); 98 | if (!response.ok) throw new Error("Failed to fetch requests"); 99 | return response.json(); 100 | } 101 | 102 | async clearRequests(): Promise { 103 | const response = await fetch(`${this.baseURL}/clear`, { 104 | method: "POST", 105 | }); 106 | if (!response.ok) throw new Error("Failed to clear requests"); 107 | } 108 | 109 | async compareRequests(requestIds: string[]): Promise { 110 | const params = requestIds.map((id) => `id=${id}`).join("&"); 111 | const response = await fetch(`${this.baseURL}/compare?${params}`); 112 | if (!response.ok) throw new Error("Failed to compare requests"); 113 | return response.json(); 114 | } 115 | 116 | async replayRequest(request: ReplayRequest): Promise { 117 | const response = await fetch(`${this.baseURL}/replay`, { 118 | method: "POST", 119 | headers: { 120 | "Content-Type": "application/json", 121 | }, 122 | body: JSON.stringify(request), 123 | }); 124 | if (!response.ok) throw new Error("Failed to replay request"); 125 | return response.json(); 126 | } 127 | 128 | async getMetrics(requestId: string): Promise { 129 | const response = await fetch(`${this.baseURL}/metrics?id=${requestId}`); 130 | if (!response.ok) throw new Error("Failed to fetch metrics"); 131 | return response.json(); 132 | } 133 | 134 | async getFlameGraph(requestId: string): Promise { 135 | const response = await fetch(`${this.baseURL}/flamegraph?id=${requestId}`); 136 | if (!response.ok) throw new Error("Failed to fetch flame graph"); 137 | return response.json(); 138 | } 139 | 140 | async getBottlenecks(): Promise { 141 | const response = await fetch(`${this.baseURL}/bottlenecks`); 142 | if (!response.ok) throw new Error("Failed to fetch bottlenecks"); 143 | return response.json(); 144 | } 145 | 146 | async getSystemInfo(): Promise { 147 | const response = await fetch(`${this.baseURL}/system-info`); 148 | if (!response.ok) throw new Error("Failed to fetch system info"); 149 | return response.json(); 150 | } 151 | 152 | subscribeToEvents(onMessage: (data: RequestLog[]) => void): EventSource { 153 | const eventSource = new EventSource(`${this.baseURL}/events`); 154 | 155 | eventSource.onmessage = (event) => { 156 | try { 157 | const data = JSON.parse(event.data); 158 | onMessage(data); 159 | } catch (error) { 160 | console.error("Failed to parse event data:", error); 161 | } 162 | }; 163 | 164 | eventSource.onerror = (error) => { 165 | console.error("EventSource error:", error); 166 | }; 167 | 168 | return eventSource; 169 | } 170 | 171 | // Export requests as JSON 172 | exportRequests(requests: RequestLog[]): string { 173 | return JSON.stringify(requests, null, 2); 174 | } 175 | 176 | // Import requests from JSON 177 | importRequests(jsonString: string): RequestLog[] { 178 | try { 179 | const data = JSON.parse(jsonString); 180 | if (Array.isArray(data)) { 181 | return data as RequestLog[]; 182 | } 183 | throw new Error("Invalid format: expected an array of requests"); 184 | } catch (error) { 185 | throw new Error(`Failed to import requests: ${error.message}`); 186 | } 187 | } 188 | } 189 | 190 | export const api = new API(); 191 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration Options 2 | 3 | GoVisual provides numerous configuration options to customize its behavior to fit your specific needs. 4 | 5 | ## Setting Configuration Options 6 | 7 | All configuration is done through option functions passed to the `govisual.Wrap()` function: 8 | 9 | ```go 10 | handler := govisual.Wrap( 11 | originalHandler, 12 | option1, 13 | option2, 14 | // ...more options 15 | ) 16 | ``` 17 | 18 | ## Available Options 19 | 20 | ### Core Features 21 | 22 | | Option | Description | Default | Example | 23 | | ------------------------------- | ------------------------------------- | ---------- | ------------------------------------------------- | 24 | | `WithMaxRequests(int)` | Number of requests to store in memory | 100 | `govisual.WithMaxRequests(500)` | 25 | | `WithDashboardPath(string)` | URL path for the dashboard | "/\_\_viz" | `govisual.WithDashboardPath("/__debug")` | 26 | | `WithRequestBodyLogging(bool)` | Enable logging of request bodies | false | `govisual.WithRequestBodyLogging(true)` | 27 | | `WithResponseBodyLogging(bool)` | Enable logging of response bodies | false | `govisual.WithResponseBodyLogging(true)` | 28 | | `WithIgnorePaths(...string)` | Paths to exclude from logging | [] | `govisual.WithIgnorePaths("/health", "/metrics")` | 29 | 30 | ### Storage Options 31 | 32 | | Option | Description | Default | Example | 33 | | --------------------------------------------------------- | ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------- | 34 | | `WithMemoryStorage()` | Use in-memory storage (default) | N/A | `govisual.WithMemoryStorage()` | 35 | | `WithPostgresStorage(connStr, tableName)` | Use PostgreSQL storage | N/A | `govisual.WithPostgresStorage("postgres://user:pass@localhost:5432/db", "govisual_requests")` | 36 | | `WithRedisStorage(connStr, ttl)` | Use Redis storage | N/A | `govisual.WithRedisStorage("redis://localhost:6379/0", 86400)` | 37 | | `WithMongoDBStorage(uri, databaseName, collectionName)` | Use MongoDB storage | N/A | `govisual.WithMongoDBStorage("mongodb://user:password@localhost:27017/", "your_database", "your_collection")` | 38 | 39 | ### OpenTelemetry Options 40 | 41 | | Option | Description | Default | Example | 42 | | ---------------------------- | ------------------------------------------ | ---------------- | -------------------------------------------------- | 43 | | `WithOpenTelemetry(bool)` | Enable OpenTelemetry integration | false | `govisual.WithOpenTelemetry(true)` | 44 | | `WithServiceName(string)` | Service name for OpenTelemetry | "govisual" | `govisual.WithServiceName("my-service")` | 45 | | `WithServiceVersion(string)` | Service version for OpenTelemetry | "dev" | `govisual.WithServiceVersion("1.0.0")` | 46 | | `WithOTelEndpoint(string)` | OTLP endpoint for exporting telemetry data | "localhost:4317" | `govisual.WithOTelEndpoint("otel-collector:4317")` | 47 | 48 | ## Configuration Examples 49 | 50 | ### Basic Configuration 51 | 52 | ```go 53 | handler := govisual.Wrap( 54 | mux, 55 | govisual.WithMaxRequests(100), 56 | govisual.WithRequestBodyLogging(true), 57 | govisual.WithResponseBodyLogging(true), 58 | ) 59 | ``` 60 | 61 | ### Custom Dashboard Path 62 | 63 | ```go 64 | handler := govisual.Wrap( 65 | mux, 66 | govisual.WithDashboardPath("/__debug"), 67 | ) 68 | ``` 69 | 70 | ### PostgreSQL Storage 71 | 72 | ```go 73 | handler := govisual.Wrap( 74 | mux, 75 | govisual.WithPostgresStorage( 76 | "postgres://user:password@localhost:5432/database?sslmode=disable", 77 | "govisual_requests" 78 | ), 79 | ) 80 | ``` 81 | 82 | ### OpenTelemetry Integration 83 | 84 | ```go 85 | handler := govisual.Wrap( 86 | mux, 87 | govisual.WithOpenTelemetry(true), 88 | govisual.WithServiceName("my-api"), 89 | govisual.WithServiceVersion("1.2.3"), 90 | govisual.WithOTelEndpoint("otel-collector:4317"), 91 | ) 92 | ``` 93 | 94 | ### Complete Example 95 | 96 | ```go 97 | handler := govisual.Wrap( 98 | mux, 99 | govisual.WithMaxRequests(500), 100 | govisual.WithDashboardPath("/__debug"), 101 | govisual.WithRequestBodyLogging(true), 102 | govisual.WithResponseBodyLogging(true), 103 | govisual.WithIgnorePaths("/health", "/metrics", "/public/*"), 104 | govisual.WithRedisStorage("redis://localhost:6379/0", 86400), 105 | govisual.WithOpenTelemetry(true), 106 | govisual.WithServiceName("user-service"), 107 | govisual.WithServiceVersion("2.0.1"), 108 | govisual.WithMongoDBStorage("mongodb://user:password@localhost:27017/", "your_database", "your_collection") 109 | ) 110 | ``` 111 | 112 | ## Related Documentation 113 | 114 | - [Storage Backends](storage-backends.md) - Detailed information about storage options 115 | - [OpenTelemetry Integration](opentelemetry.md) - In-depth guide for using OpenTelemetry 116 | - [Quick Start Guide](quick-start.md) - Getting started with GoVisual 117 | -------------------------------------------------------------------------------- /docs/api-reference.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | This document provides a complete reference for the GoVisual API. 4 | 5 | ## Core Functions 6 | 7 | ### `Wrap` 8 | 9 | ```go 10 | func Wrap(handler http.Handler, opts ...Option) http.Handler 11 | ``` 12 | 13 | Wraps an HTTP handler with GoVisual middleware to enable request visualization. 14 | 15 | **Parameters:** 16 | 17 | - `handler http.Handler`: The original HTTP handler to wrap 18 | - `opts ...Option`: Configuration options 19 | 20 | **Returns:** 21 | 22 | - `http.Handler`: The wrapped handler 23 | 24 | **Example:** 25 | 26 | ```go 27 | wrapped := govisual.Wrap(originalHandler, options...) 28 | ``` 29 | 30 | ## Configuration Options 31 | 32 | ### Request Handling Options 33 | 34 | #### WithMaxRequests 35 | 36 | ```go 37 | func WithMaxRequests(max int) Option 38 | ``` 39 | 40 | Sets the maximum number of requests to store in memory. 41 | 42 | **Parameters:** 43 | 44 | - `max int`: Maximum number of requests to store 45 | 46 | **Example:** 47 | 48 | ```go 49 | govisual.WithMaxRequests(500) 50 | ``` 51 | 52 | #### WithDashboardPath 53 | 54 | ```go 55 | func WithDashboardPath(path string) Option 56 | ``` 57 | 58 | Sets the URL path where the dashboard will be accessible. 59 | 60 | **Parameters:** 61 | 62 | - `path string`: URL path for the dashboard 63 | 64 | **Example:** 65 | 66 | ```go 67 | govisual.WithDashboardPath("/__debug") 68 | ``` 69 | 70 | #### WithRequestBodyLogging 71 | 72 | ```go 73 | func WithRequestBodyLogging(enabled bool) Option 74 | ``` 75 | 76 | Enables or disables logging of request bodies. 77 | 78 | **Parameters:** 79 | 80 | - `enabled bool`: Whether to log request bodies 81 | 82 | **Example:** 83 | 84 | ```go 85 | govisual.WithRequestBodyLogging(true) 86 | ``` 87 | 88 | #### WithResponseBodyLogging 89 | 90 | ```go 91 | func WithResponseBodyLogging(enabled bool) Option 92 | ``` 93 | 94 | Enables or disables logging of response bodies. 95 | 96 | **Parameters:** 97 | 98 | - `enabled bool`: Whether to log response bodies 99 | 100 | **Example:** 101 | 102 | ```go 103 | govisual.WithResponseBodyLogging(true) 104 | ``` 105 | 106 | #### WithIgnorePaths 107 | 108 | ```go 109 | func WithIgnorePaths(patterns ...string) Option 110 | ``` 111 | 112 | Sets path patterns to ignore from request logging. 113 | 114 | **Parameters:** 115 | 116 | - `patterns ...string`: Path patterns to ignore 117 | 118 | **Example:** 119 | 120 | ```go 121 | govisual.WithIgnorePaths("/health", "/metrics", "/static/*") 122 | ``` 123 | 124 | ### Storage Options 125 | 126 | #### WithMemoryStorage 127 | 128 | ```go 129 | func WithMemoryStorage() Option 130 | ``` 131 | 132 | Configures GoVisual to use in-memory storage (default). 133 | 134 | **Example:** 135 | 136 | ```go 137 | govisual.WithMemoryStorage() 138 | ``` 139 | 140 | #### WithPostgresStorage 141 | 142 | ```go 143 | func WithPostgresStorage(connStr string, tableName string) Option 144 | ``` 145 | 146 | Configures GoVisual to use PostgreSQL storage. 147 | 148 | **Parameters:** 149 | 150 | - `connStr string`: PostgreSQL connection string 151 | - `tableName string`: Name of the table to use 152 | 153 | **Example:** 154 | 155 | ```go 156 | govisual.WithPostgresStorage( 157 | "postgres://user:password@localhost:5432/dbname?sslmode=disable", 158 | "govisual_requests" 159 | ) 160 | ``` 161 | 162 | #### WithRedisStorage 163 | 164 | ```go 165 | func WithRedisStorage(connStr string, ttlSeconds int) Option 166 | ``` 167 | 168 | Configures GoVisual to use Redis storage. 169 | 170 | **Parameters:** 171 | 172 | - `connStr string`: Redis connection string 173 | - `ttlSeconds int`: Time-to-live in seconds 174 | 175 | **Example:** 176 | 177 | ```go 178 | govisual.WithRedisStorage("redis://localhost:6379/0", 86400) 179 | ``` 180 | 181 | ### WithMongoDBStorage 182 | 183 | ```go 184 | func WithMongoDBStorage(uri, databaseName, collectionName string) 185 | ``` 186 | 187 | Configures GoVisual to use MongoDB storage. 188 | 189 | **Parameters:** 190 | 191 | - `uri string`: MongoDB connection URI 192 | - `databaseName string`: Name of the database to use 193 | - `collectionName string`: Name of the collection to use 194 | 195 | **Example:** 196 | 197 | ```go 198 | govisual.WithMongoDBStorage("mongodb://user:password@localhost:27017", "your_database", "your_collection") 199 | ``` 200 | 201 | ### OpenTelemetry Options 202 | 203 | #### WithOpenTelemetry 204 | 205 | ```go 206 | func WithOpenTelemetry(enabled bool) Option 207 | ``` 208 | 209 | Enables or disables OpenTelemetry instrumentation. 210 | 211 | **Parameters:** 212 | 213 | - `enabled bool`: Whether to enable OpenTelemetry 214 | 215 | **Example:** 216 | 217 | ```go 218 | govisual.WithOpenTelemetry(true) 219 | ``` 220 | 221 | #### WithServiceName 222 | 223 | ```go 224 | func WithServiceName(name string) Option 225 | ``` 226 | 227 | Sets the service name for OpenTelemetry. 228 | 229 | **Parameters:** 230 | 231 | - `name string`: Service name 232 | 233 | **Example:** 234 | 235 | ```go 236 | govisual.WithServiceName("my-service") 237 | ``` 238 | 239 | #### WithServiceVersion 240 | 241 | ```go 242 | func WithServiceVersion(version string) Option 243 | ``` 244 | 245 | Sets the service version for OpenTelemetry. 246 | 247 | **Parameters:** 248 | 249 | - `version string`: Service version 250 | 251 | **Example:** 252 | 253 | ```go 254 | govisual.WithServiceVersion("1.0.0") 255 | ``` 256 | 257 | #### WithOTelEndpoint 258 | 259 | ```go 260 | func WithOTelEndpoint(endpoint string) Option 261 | ``` 262 | 263 | Sets the OTLP endpoint for exporting telemetry data. 264 | 265 | **Parameters:** 266 | 267 | - `endpoint string`: OTLP endpoint 268 | 269 | **Example:** 270 | 271 | ```go 272 | govisual.WithOTelEndpoint("otel-collector:4317") 273 | ``` 274 | 275 | ## Related Documentation 276 | 277 | - [Configuration Options](configuration.md) - Detailed configuration guide 278 | - [Storage Backends](storage-backends.md) - Storage backend documentation 279 | - [OpenTelemetry Integration](opentelemetry.md) - OpenTelemetry integration guide 280 | -------------------------------------------------------------------------------- /internal/store/mongodb.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/doganarif/govisual/internal/model" 9 | "go.mongodb.org/mongo-driver/v2/bson" 10 | "go.mongodb.org/mongo-driver/v2/mongo" 11 | "go.mongodb.org/mongo-driver/v2/mongo/options" 12 | "go.mongodb.org/mongo-driver/v2/mongo/readpref" 13 | ) 14 | 15 | // MongoDBStore implements the Store interface with MongoDB as backend 16 | type MongoDBStore struct { 17 | database *mongo.Database 18 | collection *mongo.Collection 19 | capacity int 20 | ctx context.Context 21 | } 22 | 23 | // NewMongoDBStore creates a new MongoDB-backend store 24 | func NewMongoDBStore(uri, databaseName, collectionName string, capacity int) (*MongoDBStore, error) { 25 | if capacity <= 0 { 26 | capacity = 100 27 | } 28 | 29 | ctx := context.Background() 30 | client, err := mongo.Connect(options.Client().ApplyURI(uri)) 31 | if err != nil { 32 | return nil, fmt.Errorf("failed to get MongoDB client: %w", err) 33 | } 34 | 35 | // Test the connection 36 | if err := client.Ping(ctx, readpref.Nearest()); err != nil { 37 | return nil, fmt.Errorf("failed to ping MongoDB: %w", err) 38 | } 39 | 40 | database := client.Database(databaseName) 41 | collection := database.Collection(collectionName) 42 | 43 | // Create index on timestamp for faster retrieval 44 | indexName := fmt.Sprintf("%s_timestamp_idx", collectionName) 45 | indexModel := mongo.IndexModel{ 46 | Keys: bson.M{"Timestamp": -1}, 47 | Options: options.Index().SetName(indexName), 48 | } 49 | 50 | _, err = collection.Indexes().CreateOne(ctx, indexModel) 51 | if err != nil { 52 | return nil, fmt.Errorf("failed to create index in MongoDB: %w", err) 53 | } 54 | return &MongoDBStore{ 55 | database: database, 56 | collection: collection, 57 | capacity: capacity, 58 | ctx: ctx, 59 | }, nil 60 | } 61 | 62 | // Add adds a new request log to the store 63 | func (m *MongoDBStore) Add(reqLog *model.RequestLog) { 64 | // Store log in MongoDB 65 | if _, err := m.collection.InsertOne(m.ctx, reqLog); err != nil { 66 | log.Printf("Failed to store log in MongoDB: %v", err) 67 | return 68 | } 69 | m.cleanup() 70 | } 71 | 72 | // cleanup removes old logs to maintain the capacity limit 73 | func (m *MongoDBStore) cleanup() { 74 | count, err := m.collection.CountDocuments(m.ctx, bson.M{}) 75 | if err != nil { 76 | log.Printf("Failed to get the log count in MongoDB: %v", err) 77 | return 78 | } 79 | 80 | if count <= int64(m.capacity) { 81 | return 82 | } 83 | // Find the oldest logs that exceed capacity 84 | findOptions := options.Find(). 85 | SetSort(bson.D{{Key: "Timestamp", Value: 1}}). 86 | SetLimit(count - int64(m.capacity)) 87 | 88 | cursor, err := m.collection.Find(m.ctx, bson.M{}, findOptions) 89 | if err != nil { 90 | log.Printf("Failed to find oldest logs in MongoDB: %v", err) 91 | return 92 | } 93 | defer cursor.Close(m.ctx) 94 | 95 | var oldestLogs []model.RequestLog 96 | for cursor.Next(m.ctx) { 97 | var reqLog model.RequestLog 98 | if err := cursor.Decode(&reqLog); err != nil { 99 | log.Printf("Failed to decode oldest log in MongoDB: %v", err) 100 | continue 101 | } 102 | oldestLogs = append(oldestLogs, reqLog) 103 | } 104 | 105 | if len(oldestLogs) == 0 { 106 | return 107 | } 108 | 109 | // Extract IDs of logs to delete 110 | var ids []string 111 | for _, log := range oldestLogs { 112 | ids = append(ids, log.ID) 113 | } 114 | 115 | // Delete the oldest logs 116 | if _, err := m.collection.DeleteMany(m.ctx, bson.M{"_id": bson.M{"$in": ids}}); err != nil { 117 | log.Printf("Failed to delete oldest logs in MongoDB: %v", err) 118 | return 119 | } 120 | } 121 | 122 | // Get retrieves a specific request log by its ID 123 | func (m *MongoDBStore) Get(id string) (*model.RequestLog, bool) { 124 | var reqLog model.RequestLog 125 | if err := m.collection.FindOne(m.ctx, bson.M{"_id": id}).Decode(&reqLog); err != nil { 126 | if err == mongo.ErrNoDocuments { 127 | return nil, false 128 | } 129 | log.Printf("Failed to get request log from MongoDB: %v", err) 130 | return nil, false 131 | } 132 | return &reqLog, true 133 | } 134 | 135 | // GetAll returns all stored request logs 136 | func (m *MongoDBStore) GetAll() []*model.RequestLog { 137 | opts := options.Find().SetSort(bson.M{"Timestamp": -1}) 138 | cursor, err := m.collection.Find(m.ctx, bson.M{}, opts) 139 | if err != nil { 140 | if err == mongo.ErrClientDisconnected { 141 | return nil 142 | } 143 | log.Printf("Failed to get cursor from MongoDB: %v", err) 144 | return nil 145 | } 146 | defer cursor.Close(m.ctx) 147 | reqsLog := make([]*model.RequestLog, 0) 148 | for cursor.Next(m.ctx) { 149 | var reqLog model.RequestLog 150 | if err := cursor.Decode(&reqLog); err != nil { 151 | log.Printf("Failed to decode request log from MongoDB: %v", err) 152 | continue 153 | } 154 | reqsLog = append(reqsLog, &reqLog) 155 | } 156 | return reqsLog 157 | } 158 | 159 | // GetLatest returns the n most recent request logs 160 | func (m *MongoDBStore) GetLatest(n int) []*model.RequestLog { 161 | // Get the n newest log IDs 162 | opts := options.Find().SetLimit(int64(n)).SetSort(bson.M{"timestamp": -1}) 163 | cursor, err := m.collection.Find(m.ctx, bson.M{}, opts) 164 | if err != nil { 165 | if err == mongo.ErrClientDisconnected { 166 | return nil 167 | } 168 | log.Printf("Failed to get cursor from MongoDB: %v", err) 169 | return nil 170 | } 171 | defer cursor.Close(m.ctx) 172 | reqsLog := make([]*model.RequestLog, 0) 173 | for cursor.Next(m.ctx) { 174 | var reqLog model.RequestLog 175 | if err := cursor.Decode(&reqLog); err != nil { 176 | log.Printf("Failed to decode request log from MongoDB: %v", err) 177 | continue 178 | } 179 | reqsLog = append(reqsLog, &reqLog) 180 | } 181 | 182 | return reqsLog 183 | } 184 | 185 | // Clear removes all logs from the store 186 | func (m *MongoDBStore) Clear() error { 187 | _, err := m.collection.DeleteMany(m.ctx, bson.M{}) 188 | if err != nil { 189 | return fmt.Errorf("failed to clear logs in MongoDB: %w", err) 190 | } 191 | return nil 192 | } 193 | 194 | // Close closes the database connection 195 | func (m *MongoDBStore) Close() error { 196 | return m.database.Client().Disconnect(m.ctx) 197 | } 198 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/EnvironmentInfo.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { useState, useEffect } from "preact/hooks"; 3 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"; 4 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table"; 5 | 6 | interface SystemInfo { 7 | goVersion: string; 8 | goos: string; 9 | goarch: string; 10 | hostname: string; 11 | cpuCores: number; 12 | memoryUsed: number; 13 | memoryTotal: number; 14 | envVars: Record; 15 | } 16 | 17 | export function EnvironmentInfo() { 18 | const [systemInfo, setSystemInfo] = useState({ 19 | goVersion: "Loading...", 20 | goos: "Loading...", 21 | goarch: "Loading...", 22 | hostname: "Loading...", 23 | cpuCores: 0, 24 | memoryUsed: 0, 25 | memoryTotal: 0, 26 | envVars: {} 27 | }); 28 | 29 | useEffect(() => { 30 | // Fetch system info from API 31 | fetchSystemInfo(); 32 | }, []); 33 | 34 | const fetchSystemInfo = async () => { 35 | try { 36 | const response = await fetch("/__viz/api/system-info"); 37 | if (response.ok) { 38 | const data = await response.json(); 39 | setSystemInfo(data); 40 | } 41 | } catch (error) { 42 | console.error("Failed to fetch system info:", error); 43 | // Set default values for demo 44 | setSystemInfo({ 45 | goVersion: "go1.21.0", 46 | goos: "darwin", 47 | goarch: "arm64", 48 | hostname: "localhost", 49 | cpuCores: navigator.hardwareConcurrency || 4, 50 | memoryUsed: 256, 51 | memoryTotal: 1024, 52 | envVars: { 53 | PATH: "/usr/local/bin:/usr/bin:/bin", 54 | HOME: "/Users/user", 55 | GOPATH: "/Users/user/go" 56 | } 57 | }); 58 | } 59 | }; 60 | 61 | const memoryPercentage = (systemInfo.memoryUsed / systemInfo.memoryTotal) * 100; 62 | 63 | return ( 64 |
65 |
66 | {/* Go Environment */} 67 | 68 | 69 | Go Environment 70 | 71 | 72 |
73 | Version: 74 | {systemInfo.goVersion} 75 |
76 |
77 | GOOS: 78 | {systemInfo.goos} 79 |
80 |
81 | GOARCH: 82 | {systemInfo.goarch} 83 |
84 |
85 |
86 | 87 | {/* System Info */} 88 | 89 | 90 | System 91 | 92 | 93 |
94 | Hostname: 95 | {systemInfo.hostname} 96 |
97 |
98 | OS: 99 | {systemInfo.goos} 100 |
101 |
102 | CPU Cores: 103 | {systemInfo.cpuCores} 104 |
105 |
106 |
107 | 108 | {/* Memory Usage */} 109 | 110 | 111 | Memory Usage 112 | 113 | 114 |
115 |
116 |
120 |
121 |
122 | 123 | {systemInfo.memoryUsed}MB / {systemInfo.memoryTotal}MB 124 | 125 | 126 | {memoryPercentage.toFixed(1)}% 127 | 128 |
129 |
130 | 131 | 132 |
133 | 134 | {/* Environment Variables */} 135 | 136 | 137 | Environment Variables 138 | System environment variables (sensitive values are redacted) 139 | 140 | 141 |
142 | 143 | 144 | 145 | Name 146 | Value 147 | 148 | 149 | 150 | {Object.entries(systemInfo.envVars).map(([key, value]) => ( 151 | 152 | {key} 153 | 154 | {value} 155 | 156 | 157 | ))} 158 | 159 |
160 |
161 |
162 |
163 |
164 | ); 165 | } 166 | -------------------------------------------------------------------------------- /internal/dashboard/ui/src/components/ExportImport.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { useState, useRef } from "preact/hooks"; 3 | import { api, RequestLog } from "../lib/api"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardHeader, 8 | CardTitle, 9 | CardDescription, 10 | } from "./ui/card"; 11 | import { Button } from "./ui/button"; 12 | import { Badge } from "./ui/badge"; 13 | 14 | interface ExportImportProps { 15 | requests: RequestLog[]; 16 | onImport: (requests: RequestLog[]) => void; 17 | } 18 | 19 | export function ExportImport({ requests, onImport }: ExportImportProps) { 20 | const [importError, setImportError] = useState(null); 21 | const [importSuccess, setImportSuccess] = useState(false); 22 | const fileInputRef = useRef(null); 23 | 24 | const handleExport = () => { 25 | try { 26 | const jsonData = api.exportRequests(requests); 27 | const blob = new Blob([jsonData], { type: "application/json" }); 28 | const url = URL.createObjectURL(blob); 29 | const a = document.createElement("a"); 30 | a.href = url; 31 | a.download = `govisual-requests-${Date.now()}.json`; 32 | document.body.appendChild(a); 33 | a.click(); 34 | document.body.removeChild(a); 35 | URL.revokeObjectURL(url); 36 | } catch (error) { 37 | console.error("Export failed:", error); 38 | } 39 | }; 40 | 41 | const handleExportCSV = () => { 42 | try { 43 | // Convert to CSV format 44 | const headers = [ 45 | "ID", 46 | "Timestamp", 47 | "Method", 48 | "Path", 49 | "Status", 50 | "Duration (ms)", 51 | "Error", 52 | ]; 53 | const rows = requests.map((req) => [ 54 | req.ID, 55 | req.Timestamp, 56 | req.Method, 57 | req.Path, 58 | req.StatusCode, 59 | req.Duration, 60 | req.Error || "", 61 | ]); 62 | 63 | const csvContent = [ 64 | headers.join(","), 65 | ...rows.map((row) => row.map((cell) => `"${cell}"`).join(",")), 66 | ].join("\n"); 67 | 68 | const blob = new Blob([csvContent], { type: "text/csv" }); 69 | const url = URL.createObjectURL(blob); 70 | const a = document.createElement("a"); 71 | a.href = url; 72 | a.download = `govisual-requests-${Date.now()}.csv`; 73 | document.body.appendChild(a); 74 | a.click(); 75 | document.body.removeChild(a); 76 | URL.revokeObjectURL(url); 77 | } catch (error) { 78 | console.error("CSV export failed:", error); 79 | } 80 | }; 81 | 82 | const handleImport = (event: Event) => { 83 | const target = event.target as HTMLInputElement; 84 | const file = target.files?.[0]; 85 | 86 | if (!file) return; 87 | 88 | setImportError(null); 89 | setImportSuccess(false); 90 | 91 | const reader = new FileReader(); 92 | reader.onload = (e) => { 93 | try { 94 | const content = e.target?.result as string; 95 | const importedRequests = api.importRequests(content); 96 | onImport(importedRequests); 97 | setImportSuccess(true); 98 | setTimeout(() => setImportSuccess(false), 3000); 99 | } catch (error) { 100 | setImportError(error.message || "Failed to import requests"); 101 | setTimeout(() => setImportError(null), 5000); 102 | } 103 | }; 104 | 105 | reader.readAsText(file); 106 | 107 | // Reset file input 108 | target.value = ""; 109 | }; 110 | 111 | const handleImportClick = () => { 112 | fileInputRef.current?.click(); 113 | }; 114 | 115 | return ( 116 | 117 | 118 | Export & Import 119 | 120 | Export request logs for analysis or import previously saved logs 121 | 122 | 123 | 124 |
125 |
126 |

Export Data

127 |
128 | 135 | 143 |
144 | {requests.length === 0 && ( 145 |

146 | No requests to export 147 |

148 | )} 149 | {requests.length > 0 && ( 150 |

151 | {requests.length} request{requests.length !== 1 ? "s" : ""} will 152 | be exported 153 |

154 | )} 155 |
156 | 157 |
158 |

Import Data

159 | 166 | 173 |

174 | Import previously exported request logs 175 |

176 |
177 |
178 | 179 | {importError && ( 180 |
181 |

{importError}

182 |
183 | )} 184 | 185 | {importSuccess && ( 186 |
187 |

188 | Requests imported successfully! 189 |

190 |
191 | )} 192 |
193 |
194 | ); 195 | } 196 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | ## General Questions 4 | 5 | ### What is GoVisual? 6 | 7 | GoVisual is a lightweight, zero-configuration HTTP request visualizer and debugger for Go web applications. It helps you monitor, inspect, and debug HTTP requests during local development. 8 | 9 | ### How does GoVisual work? 10 | 11 | GoVisual works as middleware that wraps your existing HTTP handlers. It intercepts requests and responses, collects information about them, and provides a dashboard interface to visualize this data. 12 | 13 | ### Is GoVisual suitable for production use? 14 | 15 | GoVisual is primarily designed for local development and testing environments. While it can be used in production for debugging purposes, we recommend enabling it selectively or using it with caution due to: 16 | 17 | - Potential performance impact when logging all requests 18 | - Memory usage when storing request and response bodies 19 | - Security considerations for sensitive information 20 | 21 | For production monitoring, consider using OpenTelemetry integration with a proper observability backend. 22 | 23 | ## Implementation 24 | 25 | ### Can I use GoVisual with any Go HTTP router/framework? 26 | 27 | Yes, GoVisual works with any Go HTTP handler that implements the `http.Handler` interface, including: 28 | 29 | - Standard library `http.ServeMux` 30 | - Gorilla Mux 31 | - Chi 32 | - Echo (with adapter) 33 | - Gin (with adapter) 34 | 35 | ### How do I use GoVisual with Echo/Gin/Fiber frameworks? 36 | 37 | For frameworks that don't directly use the standard `http.Handler` interface, you'll need to use an adapter: 38 | 39 | **Echo Example:** 40 | 41 | ```go 42 | // Create Echo instance 43 | e := echo.New() 44 | 45 | // Add your routes 46 | e.GET("/hello", helloHandler) 47 | 48 | // Wrap Echo's HTTP handler with GoVisual 49 | echoHandler := govisual.Wrap(echo.WrapHandler(e)) 50 | 51 | // Start server with the wrapped handler 52 | http.ListenAndServe(":8080", echoHandler) 53 | ``` 54 | 55 | **Gin Example:** 56 | 57 | ```go 58 | // Create Gin router 59 | r := gin.Default() 60 | 61 | // Add your routes 62 | r.GET("/hello", helloHandler) 63 | 64 | // Wrap Gin's HTTP handler with GoVisual 65 | ginHandler := govisual.Wrap(r) 66 | 67 | // Start server with the wrapped handler 68 | http.ListenAndServe(":8080", ginHandler) 69 | ``` 70 | 71 | ### Does GoVisual work with WebSockets? 72 | 73 | GoVisual works with WebSocket handshake requests but does not trace the WebSocket communication itself after the connection is established. 74 | 75 | ## Configuration 76 | 77 | ### How do I change the dashboard URL? 78 | 79 | Use the `WithDashboardPath` option: 80 | 81 | ```go 82 | handler := govisual.Wrap( 83 | mux, 84 | govisual.WithDashboardPath("/__debug"), 85 | ) 86 | ``` 87 | 88 | ### Can I configure GoVisual from environment variables? 89 | 90 | GoVisual doesn't directly read environment variables, but you can easily create your own configuration setup: 91 | 92 | ```go 93 | func configureGoVisual(handler http.Handler) http.Handler { 94 | // Read config from environment 95 | maxRequests, _ := strconv.Atoi(getEnvOrDefault("GOVISUAL_MAX_REQUESTS", "100")) 96 | logBodies := getEnvOrDefault("GOVISUAL_LOG_BODIES", "false") == "true" 97 | dashPath := getEnvOrDefault("GOVISUAL_DASHBOARD_PATH", "/__viz") 98 | 99 | // Apply configuration 100 | return govisual.Wrap( 101 | handler, 102 | govisual.WithMaxRequests(maxRequests), 103 | govisual.WithRequestBodyLogging(logBodies), 104 | govisual.WithResponseBodyLogging(logBodies), 105 | govisual.WithDashboardPath(dashPath), 106 | ) 107 | } 108 | 109 | func getEnvOrDefault(key, defaultValue string) string { 110 | if value, exists := os.LookupEnv(key); exists { 111 | return value 112 | } 113 | return defaultValue 114 | } 115 | ``` 116 | 117 | ## Performance 118 | 119 | ### What is the performance impact of using GoVisual? 120 | 121 | The performance impact depends on how GoVisual is configured: 122 | 123 | - Basic request metadata logging: Minimal impact (typically < 1ms per request) 124 | - Body logging: Impact scales with the size of request/response bodies 125 | - Storage backend: In-memory is fastest, followed by Redis, then PostgreSQL 126 | - Number of requests stored: Higher numbers require more memory 127 | 128 | For best performance: 129 | 130 | - Disable body logging (`WithRequestBodyLogging(false)`, `WithResponseBodyLogging(false)`) 131 | - Use a smaller maximum request count (`WithMaxRequests(50)`) 132 | - Use Redis for storage in high-volume applications 133 | 134 | ### How can I minimize memory usage? 135 | 136 | To reduce memory usage: 137 | 138 | - Disable body logging 139 | - Reduce the number of stored requests 140 | - Use Redis storage with a low TTL 141 | - Ignore high-volume paths 142 | 143 | ## Storage 144 | 145 | ### Which storage backend should I choose? 146 | 147 | - **In-memory**: Simplest, fastest, but data is lost on restart 148 | - **Redis**: Good balance of performance and persistence, with automatic expiration 149 | - **PostgreSQL**: Best for long-term storage and complex querying 150 | 151 | ### How do I implement a custom storage backend? 152 | 153 | Implement the `Store` interface from `internal/store/store.go`: 154 | 155 | ```go 156 | type Store interface { 157 | AddRequest(log *RequestLog) error 158 | GetRequest(id string) (*RequestLog, error) 159 | GetRequests() ([]*RequestLog, error) 160 | Clear() error 161 | Close() error 162 | } 163 | ``` 164 | 165 | Then use it directly in your application. 166 | 167 | ## Other 168 | 169 | ### Does GoVisual work with HTTPS? 170 | 171 | Yes, GoVisual works with both HTTP and HTTPS servers. It operates at the handler level, so the transport protocol doesn't matter. 172 | 173 | ### Can I use GoVisual with gRPC? 174 | 175 | GoVisual is designed for HTTP traffic. While it doesn't directly support gRPC, you can use it alongside gRPC servers by running it on a different port. 176 | 177 | ### How is GoVisual different from other debugging tools? 178 | 179 | Compared to other tools: 180 | 181 | - **vs. net/http/pprof**: GoVisual focuses on HTTP request visualization, while pprof is for profiling CPU, memory, etc. 182 | - **vs. debugcharts**: GoVisual traces HTTP requests, while debugcharts visualizes runtime statistics. 183 | - **vs. APM tools**: GoVisual is lightweight and requires no external services, designed specifically for local development. 184 | 185 | ## Related Documentation 186 | 187 | - [Configuration Options](configuration.md) - Available configuration options 188 | - [Storage Backends](storage-backends.md) - Different storage options 189 | - [Troubleshooting](troubleshooting.md) - Common issues and solutions 190 | -------------------------------------------------------------------------------- /cmd/examples/multistorage/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | "time" 10 | 11 | "github.com/doganarif/govisual" 12 | _ "github.com/mattn/go-sqlite3" // Import your preferred SQLite driver 13 | ) 14 | 15 | func main() { 16 | // Default storage is in-memory, but can be changed via environment variables 17 | var opts []govisual.Option 18 | 19 | // Add basic options 20 | opts = append(opts, 21 | govisual.WithMaxRequests(100), 22 | govisual.WithRequestBodyLogging(true), 23 | govisual.WithResponseBodyLogging(true), 24 | ) 25 | 26 | // Check for storage configuration 27 | storageType := os.Getenv("GOVISUAL_STORAGE_TYPE") 28 | 29 | switch storageType { 30 | case "postgres": 31 | connStr := os.Getenv("GOVISUAL_PG_CONN") 32 | if connStr == "" { 33 | log.Fatal("PostgreSQL connection string not provided in GOVISUAL_PG_CONN") 34 | } 35 | tableName := os.Getenv("GOVISUAL_PG_TABLE") 36 | if tableName == "" { 37 | tableName = "govisual_requests" 38 | } 39 | opts = append(opts, govisual.WithPostgresStorage(connStr, tableName)) 40 | log.Printf("Using PostgreSQL storage with table: %s", tableName) 41 | 42 | case "redis": 43 | connStr := os.Getenv("GOVISUAL_REDIS_CONN") 44 | if connStr == "" { 45 | log.Fatal("Redis connection string not provided in GOVISUAL_REDIS_CONN") 46 | } 47 | ttl := 86400 // 24 hours by default 48 | if ttlStr := os.Getenv("GOVISUAL_REDIS_TTL"); ttlStr != "" { 49 | var err error 50 | ttl, err = parseInt(ttlStr) 51 | if err != nil { 52 | log.Printf("Invalid TTL value: %s, using default of 86400 seconds", ttlStr) 53 | ttl = 86400 54 | } 55 | } 56 | opts = append(opts, govisual.WithRedisStorage(connStr, ttl)) 57 | log.Printf("Using Redis storage with TTL: %d seconds", ttl) 58 | 59 | case "sqlite": 60 | connStr := os.Getenv("GOVISUAL_SQLITE_DBPATH") 61 | if connStr == "" { 62 | log.Fatal("SQLite database path not provided in GOVISUAL_SQLITE_DBPATH") 63 | } 64 | 65 | tableName := os.Getenv("GOVISUAL_SQLITE_TABLE") 66 | if tableName == "" { 67 | tableName = "govisual_requests" 68 | } 69 | 70 | opts = append(opts, govisual.WithSQLiteStorage(connStr, tableName)) 71 | log.Printf("Using SQLite storage with table: %s", tableName) 72 | 73 | case "sqlite_with_db": 74 | // This approach avoids SQLite driver registration conflicts 75 | // by using an existing database connection 76 | 77 | connStr := os.Getenv("GOVISUAL_SQLITE_DBPATH") 78 | if connStr == "" { 79 | log.Fatal("SQLite database path not provided in GOVISUAL_SQLITE_DBPATH") 80 | } 81 | 82 | tableName := os.Getenv("GOVISUAL_SQLITE_TABLE") 83 | if tableName == "" { 84 | tableName = "govisual_requests" 85 | } 86 | 87 | // Create a database connection using your preferred SQLite driver 88 | // Note: This example uses github.com/mattn/go-sqlite3 but you can use any driver 89 | db, err := sql.Open("sqlite3", connStr) 90 | if err != nil { 91 | log.Fatalf("Failed to open SQLite database: %v", err) 92 | } 93 | defer db.Close() 94 | 95 | // Test the connection 96 | if err := db.Ping(); err != nil { 97 | log.Fatalf("Failed to connect to SQLite database: %v", err) 98 | } 99 | 100 | // Pass the existing connection to govisual 101 | opts = append(opts, govisual.WithSQLiteStorageDB(db, tableName)) 102 | log.Printf("Using SQLite storage with existing connection and table: %s", tableName) 103 | case "mongodb": 104 | uri := os.Getenv("GOVISUAL_MONGO_URI") 105 | database := os.Getenv("GOVISUAL_MONGO_DATABASE") 106 | collection := os.Getenv("GOVISUAL_MONGO_COLLECTION") 107 | opts = append(opts, govisual.WithMongoDBStorage(uri, database, collection)) 108 | default: 109 | // Default to memory storage 110 | opts = append(opts, govisual.WithMemoryStorage()) 111 | log.Println("Using in-memory storage (default)") 112 | } 113 | 114 | // Create a simple HTTP handler 115 | mux := http.NewServeMux() 116 | 117 | // Add some example routes 118 | mux.HandleFunc("/", homeHandler) 119 | mux.HandleFunc("/api/users", usersHandler) 120 | mux.HandleFunc("/api/products", productsHandler) 121 | 122 | // Wrap with GoVisual 123 | handler := govisual.Wrap(mux, opts...) 124 | 125 | // Start the server 126 | port := os.Getenv("PORT") 127 | if port == "" { 128 | port = "8080" 129 | } 130 | 131 | log.Printf("Starting server on port %s", port) 132 | log.Printf("Access the dashboard at http://localhost:%s/__viz", port) 133 | 134 | if err := http.ListenAndServe(":"+port, handler); err != nil { 135 | log.Fatalf("Server error: %v", err) 136 | } 137 | } 138 | 139 | func parseInt(s string) (int, error) { 140 | var val int 141 | _, err := fmt.Sscanf(s, "%d", &val) 142 | return val, err 143 | } 144 | 145 | func homeHandler(w http.ResponseWriter, r *http.Request) { 146 | if r.URL.Path != "/" { 147 | http.NotFound(w, r) 148 | return 149 | } 150 | 151 | w.WriteHeader(http.StatusOK) 152 | w.Write([]byte("Welcome to GoVisual Multi-Storage Example!\n\n")) 153 | w.Write([]byte("Available endpoints:\n")) 154 | w.Write([]byte("- GET /api/users: List users\n")) 155 | w.Write([]byte("- POST /api/users: Create user (with JSON body)\n")) 156 | w.Write([]byte("- GET /api/products: List products\n")) 157 | w.Write([]byte("- POST /api/products: Create product (with JSON body)\n")) 158 | w.Write([]byte("\nAccess the dashboard at /__viz\n")) 159 | } 160 | 161 | func usersHandler(w http.ResponseWriter, r *http.Request) { 162 | // Simulate some processing time 163 | time.Sleep(50 * time.Millisecond) 164 | 165 | switch r.Method { 166 | case http.MethodGet: 167 | w.Header().Set("Content-Type", "application/json") 168 | w.WriteHeader(http.StatusOK) 169 | w.Write([]byte(`{"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]}`)) 170 | 171 | case http.MethodPost: 172 | // Simulate request processing 173 | time.Sleep(100 * time.Millisecond) 174 | 175 | w.Header().Set("Content-Type", "application/json") 176 | w.WriteHeader(http.StatusCreated) 177 | w.Write([]byte(`{"id": 3, "name": "New User", "created": true}`)) 178 | 179 | default: 180 | w.WriteHeader(http.StatusMethodNotAllowed) 181 | } 182 | } 183 | 184 | func productsHandler(w http.ResponseWriter, r *http.Request) { 185 | // Simulate some processing time 186 | time.Sleep(75 * time.Millisecond) 187 | 188 | switch r.Method { 189 | case http.MethodGet: 190 | w.Header().Set("Content-Type", "application/json") 191 | w.WriteHeader(http.StatusOK) 192 | w.Write([]byte(`{"products": [{"id": 1, "name": "Laptop"}, {"id": 2, "name": "Phone"}]}`)) 193 | 194 | case http.MethodPost: 195 | // Simulate request processing 196 | time.Sleep(125 * time.Millisecond) 197 | 198 | w.Header().Set("Content-Type", "application/json") 199 | w.WriteHeader(http.StatusCreated) 200 | w.Write([]byte(`{"id": 3, "name": "New Product", "created": true}`)) 201 | 202 | default: 203 | w.WriteHeader(http.StatusMethodNotAllowed) 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /internal/profiling/flamegraph.go: -------------------------------------------------------------------------------- 1 | package profiling 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/google/pprof/profile" 10 | ) 11 | 12 | // FlameGraphNode represents a node in the flame graph 13 | type FlameGraphNode struct { 14 | Name string `json:"name"` 15 | Value int64 `json:"value"` // Time spent in nanoseconds 16 | Children []*FlameGraphNode `json:"children"` 17 | } 18 | 19 | // FlameGraph generates flame graph data from CPU profile 20 | type FlameGraph struct { 21 | Root *FlameGraphNode `json:"root"` 22 | TotalTime int64 `json:"total_time"` 23 | SampleCount int64 `json:"sample_count"` 24 | } 25 | 26 | // GenerateFlameGraph generates a flame graph from CPU profile data 27 | func GenerateFlameGraph(profileData []byte) (*FlameGraph, error) { 28 | if len(profileData) == 0 { 29 | return nil, fmt.Errorf("no profile data available") 30 | } 31 | 32 | // Parse the profile 33 | prof, err := profile.Parse(bytes.NewReader(profileData)) 34 | if err != nil { 35 | return nil, fmt.Errorf("failed to parse profile: %w", err) 36 | } 37 | 38 | // Build the flame graph 39 | root := &FlameGraphNode{ 40 | Name: "root", 41 | Value: 0, 42 | Children: make([]*FlameGraphNode, 0), 43 | } 44 | 45 | // Process each sample in the profile 46 | for _, sample := range prof.Sample { 47 | value := sample.Value[1] // CPU nanoseconds 48 | if value == 0 { 49 | continue 50 | } 51 | 52 | // Build the stack trace path 53 | path := make([]string, 0, len(sample.Location)) 54 | for i := len(sample.Location) - 1; i >= 0; i-- { 55 | loc := sample.Location[i] 56 | for j := len(loc.Line) - 1; j >= 0; j-- { 57 | line := loc.Line[j] 58 | funcName := line.Function.Name 59 | if funcName != "" { 60 | // Simplify function names 61 | funcName = simplifyFunctionName(funcName) 62 | path = append(path, funcName) 63 | } 64 | } 65 | } 66 | 67 | // Add to the tree 68 | addToTree(root, path, value) 69 | } 70 | 71 | // Calculate total time 72 | var totalTime int64 73 | for _, child := range root.Children { 74 | totalTime += child.Value 75 | } 76 | 77 | return &FlameGraph{ 78 | Root: root, 79 | TotalTime: totalTime, 80 | SampleCount: int64(len(prof.Sample)), 81 | }, nil 82 | } 83 | 84 | // addToTree adds a stack trace path to the flame graph tree 85 | func addToTree(root *FlameGraphNode, path []string, value int64) { 86 | current := root 87 | 88 | for _, name := range path { 89 | // Find or create child node 90 | var child *FlameGraphNode 91 | for _, c := range current.Children { 92 | if c.Name == name { 93 | child = c 94 | break 95 | } 96 | } 97 | 98 | if child == nil { 99 | child = &FlameGraphNode{ 100 | Name: name, 101 | Value: 0, 102 | Children: make([]*FlameGraphNode, 0), 103 | } 104 | current.Children = append(current.Children, child) 105 | } 106 | 107 | child.Value += value 108 | current = child 109 | } 110 | } 111 | 112 | // simplifyFunctionName simplifies a function name for display 113 | func simplifyFunctionName(name string) string { 114 | // Remove parameter types for cleaner display 115 | if idx := strings.Index(name, "("); idx > 0 { 116 | name = name[:idx] 117 | } 118 | 119 | // Shorten package paths 120 | parts := strings.Split(name, "/") 121 | if len(parts) > 2 { 122 | // Keep last two parts of the path 123 | name = ".../" + strings.Join(parts[len(parts)-2:], "/") 124 | } 125 | 126 | return name 127 | } 128 | 129 | // GetHotSpots identifies the hottest code paths 130 | func (fg *FlameGraph) GetHotSpots(threshold float64) []HotSpot { 131 | if fg.TotalTime == 0 { 132 | return nil 133 | } 134 | 135 | hotspots := make([]HotSpot, 0) 136 | findHotSpots(fg.Root, nil, fg.TotalTime, threshold, &hotspots) 137 | 138 | // Sort by percentage descending 139 | sort.Slice(hotspots, func(i, j int) bool { 140 | return hotspots[i].Percentage > hotspots[j].Percentage 141 | }) 142 | 143 | // Return top 10 144 | if len(hotspots) > 10 { 145 | hotspots = hotspots[:10] 146 | } 147 | 148 | return hotspots 149 | } 150 | 151 | // HotSpot represents a performance hot spot 152 | type HotSpot struct { 153 | Path []string `json:"path"` 154 | Name string `json:"name"` 155 | Time int64 `json:"time"` 156 | Percentage float64 `json:"percentage"` 157 | } 158 | 159 | // findHotSpots recursively finds hot spots in the flame graph 160 | func findHotSpots(node *FlameGraphNode, path []string, totalTime int64, threshold float64, hotspots *[]HotSpot) { 161 | if node.Value == 0 { 162 | return 163 | } 164 | 165 | percentage := float64(node.Value) / float64(totalTime) * 100 166 | 167 | // Add current path 168 | currentPath := append(path, node.Name) 169 | 170 | // Check if this node is a hotspot 171 | if percentage >= threshold { 172 | *hotspots = append(*hotspots, HotSpot{ 173 | Path: currentPath, 174 | Name: node.Name, 175 | Time: node.Value, 176 | Percentage: percentage, 177 | }) 178 | } 179 | 180 | // Recurse to children 181 | for _, child := range node.Children { 182 | findHotSpots(child, currentPath, totalTime, threshold, hotspots) 183 | } 184 | } 185 | 186 | // ConvertToD3Format converts the flame graph to D3.js compatible format 187 | func (fg *FlameGraph) ConvertToD3Format() map[string]interface{} { 188 | return convertNodeToD3(fg.Root, fg.TotalTime) 189 | } 190 | 191 | // convertNodeToD3 converts a node to D3.js format 192 | func convertNodeToD3(node *FlameGraphNode, totalTime int64) map[string]interface{} { 193 | d3Node := map[string]interface{}{ 194 | "name": node.Name, 195 | "value": node.Value, 196 | } 197 | 198 | if totalTime > 0 { 199 | d3Node["percentage"] = fmt.Sprintf("%.2f%%", float64(node.Value)/float64(totalTime)*100) 200 | } 201 | 202 | if len(node.Children) > 0 { 203 | children := make([]map[string]interface{}, len(node.Children)) 204 | for i, child := range node.Children { 205 | children[i] = convertNodeToD3(child, totalTime) 206 | } 207 | d3Node["children"] = children 208 | } 209 | 210 | return d3Node 211 | } 212 | 213 | // GenerateTextFlameGraph generates a text-based flame graph (folded stack format) 214 | func (fg *FlameGraph) GenerateTextFlameGraph() string { 215 | var buf bytes.Buffer 216 | generateTextNode(&buf, fg.Root, []string{}) 217 | return buf.String() 218 | } 219 | 220 | // generateTextNode recursively generates text representation 221 | func generateTextNode(buf *bytes.Buffer, node *FlameGraphNode, stack []string) { 222 | if node.Name != "root" { 223 | stack = append(stack, node.Name) 224 | } 225 | 226 | // Write current stack with value 227 | if len(stack) > 0 && node.Value > 0 { 228 | fmt.Fprintf(buf, "%s %d\n", strings.Join(stack, ";"), node.Value) 229 | } 230 | 231 | // Process children 232 | for _, child := range node.Children { 233 | generateTextNode(buf, child, stack) 234 | } 235 | } 236 | --------------------------------------------------------------------------------