(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 | 
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------