Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ func main() {
m.AddMonitor(requestsMonitor)

// Add logs monitor
logsMonitor, wrappedLogger := monitors.NewLogsMonitor(e.Logger)
logsMonitor, wrappedLogger := monitors.NewLogsMonitor(monitors.LogsMonitorConfig{
Logger: e.Logger,
})
e.Logger = wrappedLogger
m.AddMonitor(logsMonitor)

Expand Down
16 changes: 12 additions & 4 deletions demo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,19 @@ func main() {
// ----------------------------------------------
// logs monitor
// ----------------------------------------------
logsMonitor, wrappedLogger := monitors.NewLogsMonitor(e.Logger)
logsMonitor, wrappedLogger := monitors.NewLogsMonitor(monitors.LogsMonitorConfig{
Logger: e.Logger,
})
// Replace the Echo logger with the wrapped logger
e.Logger = wrappedLogger
m.AddMonitor(logsMonitor)

// ----------------------------------------------
// writer monitor
// ----------------------------------------------
m.AddMonitor(monitors.NewLoggerWriterMonitor(e.Logger))
m.AddMonitor(monitors.NewLoggerWriterMonitor(monitors.LoggerWriterMonitorConfig{
Logger: e.Logger,
}))

// ----------------------------------------------
// queries monitor
Expand All @@ -56,7 +61,10 @@ func main() {

// Wrap the database driver with query monitoring
var queriesMonitor *debugmonitor.Monitor
queriesMonitor, db = monitors.NewQueriesMonitor(dsn, db.Driver())
queriesMonitor, db = monitors.NewQueriesMonitor(monitors.QueriesMonitorConfig{
DSN: dsn,
Driver: db.Driver(),
})
m.AddMonitor(queriesMonitor)

// Initialize database schema
Expand All @@ -65,7 +73,7 @@ func main() {
// ----------------------------------------------
// errors monitor
// ----------------------------------------------
errorsMonitor, errorRecorder := monitors.NewErrorsMonitor()
errorsMonitor, errorRecorder := monitors.NewErrorsMonitor(monitors.ErrorsMonitorConfig{})
m.AddMonitor(errorsMonitor)

// Wrap the default error handler to record errors
Expand Down
26 changes: 26 additions & 0 deletions ssehelpers.go → helpers.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
package debugmonitor

import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"net/http"
"strconv"
"time"

"github.com/labstack/echo/v4"
)

// RenderTemplate executes a template with the given data and returns the result as HTML response.
func RenderTemplate(c echo.Context, tmpl *template.Template, data any) error {
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}

func HandleSSEStream(c echo.Context, store *Store) error {
// Parse the sinceID parameter
sinceID := int64(0)
Expand Down Expand Up @@ -82,3 +93,18 @@ func sendSSEEvent(c echo.Context, entry *DataEntry) error {
_, err = fmt.Fprintf(c.Response().Writer, "data: %s\n\n", data)
return err
}

// HandleDataJSON returns store entries as JSON for polling mode.
// It accepts a "since" query parameter to return only entries with ID greater than the specified value.
func HandleDataJSON(c echo.Context, store *Store) error {
// Parse the sinceID parameter
sinceID := int64(0)
if sinceIDStr := c.QueryParam("since"); sinceIDStr != "" {
if id, err := strconv.ParseInt(sinceIDStr, 10, 64); err == nil {
sinceID = id
}
}

entries := store.GetSince(sinceID)
return c.JSON(http.StatusOK, entries)
}
29 changes: 22 additions & 7 deletions monitors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package monitors
import (
_ "embed"
"fmt"
"html/template"
"net/http"
"time"

Expand All @@ -22,12 +23,21 @@ type ErrorPayload struct {
//go:embed errors.html
var errorsView string

// errorsViewTemplate is the parsed template for the errors view
var errorsViewTemplate = template.Must(template.New("errorsView").Parse(errorsView))

// ErrorRecorder is a function type for recording errors
type ErrorRecorder func(err error)

// ErrorsMonitorConfig defines the config for Errors monitor.
type ErrorsMonitorConfig struct {
// UsePolling enables polling mode instead of SSE for real-time updates.
UsePolling bool
}

// NewErrorsMonitor creates a new monitor for errors and returns
// the monitor along with an error recording function
func NewErrorsMonitor() (*debugmonitor.Monitor, ErrorRecorder) {
func NewErrorsMonitor(config ErrorsMonitorConfig) (*debugmonitor.Monitor, ErrorRecorder) {
m := &debugmonitor.Monitor{
Name: "errors",
DisplayName: "Errors",
Expand All @@ -36,10 +46,15 @@ func NewErrorsMonitor() (*debugmonitor.Monitor, ErrorRecorder) {
ActionHandler: func(c echo.Context, store *debugmonitor.Store, action string) error {
switch action {
case "render":
return c.HTML(http.StatusOK, errorsView)
return debugmonitor.RenderTemplate(c, errorsViewTemplate, map[string]any{
"UsePolling": config.UsePolling,
})
case "stream":
// SSE endpoint for real-time updates
return debugmonitor.HandleSSEStream(c, store)
case "data":
// JSON endpoint for polling mode
return debugmonitor.HandleDataJSON(c, store)
default:
return echo.NewHTTPError(http.StatusBadRequest)
}
Expand Down Expand Up @@ -123,11 +138,11 @@ func containsStackTrace(s string) bool {
// Simple heuristic: stack traces usually contain file paths with line numbers
// Look for patterns like ".go:" which are common in Go stack traces
return len(s) > 100 && (
// Common patterns in stack traces
containsPattern(s, ".go:") ||
containsPattern(s, "goroutine ") ||
containsPattern(s, "\tat ") ||
containsPattern(s, "\n\t"))
// Common patterns in stack traces
containsPattern(s, ".go:") ||
containsPattern(s, "goroutine ") ||
containsPattern(s, "\tat ") ||
containsPattern(s, "\n\t"))
}

// containsPattern checks if a string contains a specific pattern
Expand Down
105 changes: 98 additions & 7 deletions monitors/errors.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div x-data="errorsMonitor()" class="h-full flex flex-col" x-clock>
<div x-data="errorsMonitor({{.UsePolling}})" class="h-full flex flex-col" x-clock>
<!-- Connection status indicator and controls -->
<div class="px-4 py-2 bg-white dark:bg-gray-950 border-b dark:border-gray-700 border-gray-200 sticky top-0 left-0">
<div class="space-y-2">
Expand Down Expand Up @@ -105,18 +105,52 @@
</div>

<script>
function errorsMonitor() {
function errorsMonitor(usePolling) {
return {
entries: [],
lastId: 0,
connected: false,
liveUpdatesEnabled: true,
eventSource: null,
pollingInterval: null,
isBooted: false,
usePolling: usePolling,
searchQuery: '',

init: function () {
this.connectSSE();
// Fetch initial data first
this.fetchInitialData().then(() => {
// Then start real-time updates
if (this.usePolling) {
this.startPolling();
} else {
this.connectSSE();
}
});
},

async fetchInitialData() {
const params = new URLSearchParams(window.location.search);
const monitor = params.get('monitor');

try {
const response = await fetch(`?monitor=${monitor}&action=data&since=0`);
if (response.ok) {
const entries = await response.json();
// Add entries in reverse order (newest first for display)
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
this.entries.unshift(entry);
if (entry.id > this.lastId) {
this.lastId = entry.id;
}
}
}
} catch (error) {
console.error('Failed to fetch initial data:', error);
}

this.isBooted = true;
},

get filteredEntries() {
Expand Down Expand Up @@ -147,10 +181,68 @@

if (this.liveUpdatesEnabled) {
// Turn live updates ON
this.connectSSE();
if (this.usePolling) {
this.startPolling();
} else {
this.connectSSE();
}
} else {
// Turn live updates OFF
this.disconnectSSE();
if (this.usePolling) {
this.stopPolling();
} else {
this.disconnectSSE();
}
}
},

startPolling() {
// Don't start if live updates are disabled
if (!this.liveUpdatesEnabled) {
return;
}

// Clear existing interval if any
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
}

this.connected = true;

const params = new URLSearchParams(window.location.search);
const monitor = params.get('monitor');

// Poll every 1 second
this.pollingInterval = setInterval(async () => {
try {
const response = await fetch(`?monitor=${monitor}&action=data&since=${this.lastId}`);
if (response.ok) {
const entries = await response.json();
for (const entry of entries) {
// Mark as new for animation
entry.isNew = true;
this.entries.unshift(entry);
if (entry.id > this.lastId) {
this.lastId = entry.id;
}
// Remove isNew flag after animation completes
setTimeout(() => {
entry.isNew = false;
}, 350);
}
}
} catch (error) {
console.error('Polling error:', error);
this.connected = false;
}
}, 1000);
},

stopPolling() {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
this.connected = false;
}
},

Expand All @@ -172,8 +264,6 @@

this.eventSource.onopen = () => {
this.connected = true;
// Mark as booted once connection is established
this.isBooted = true;
};

this.eventSource.onerror = (error) => {
Expand Down Expand Up @@ -226,6 +316,7 @@
destroy() {
// Cleanup when component is destroyed
this.disconnectSSE();
this.stopPolling();
}
}
}
Expand Down
23 changes: 20 additions & 3 deletions monitors/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package monitors
import (
_ "embed"
"fmt"
"html/template"
"io"
"net/http"
"time"
Expand All @@ -22,15 +23,26 @@ type LogPayload struct {
//go:embed logs.html
var logsView string

// logsViewTemplate is the parsed template for the logs view
var logsViewTemplate = template.Must(template.New("logsView").Parse(logsView))

// LoggerWrapper wraps an echo.Logger and intercepts all logging calls
type LoggerWrapper struct {
original echo.Logger
monitor *debugmonitor.Monitor
}

// LogsMonitorConfig defines the config for Logs monitor.
type LogsMonitorConfig struct {
// Logger is the original echo.Logger to wrap with monitoring.
Logger echo.Logger
// UsePolling enables polling mode instead of SSE for real-time updates.
UsePolling bool
}

// NewLogsMonitor creates a new monitor for logging and returns
// the monitor along with a wrapped logger
func NewLogsMonitor(logger echo.Logger) (*debugmonitor.Monitor, echo.Logger) {
func NewLogsMonitor(config LogsMonitorConfig) (*debugmonitor.Monitor, echo.Logger) {
m := &debugmonitor.Monitor{
Name: "logs",
DisplayName: "Logs",
Expand All @@ -39,18 +51,23 @@ func NewLogsMonitor(logger echo.Logger) (*debugmonitor.Monitor, echo.Logger) {
ActionHandler: func(c echo.Context, store *debugmonitor.Store, action string) error {
switch action {
case "render":
return c.HTML(http.StatusOK, logsView)
return debugmonitor.RenderTemplate(c, logsViewTemplate, map[string]any{
"UsePolling": config.UsePolling,
})
case "stream":
// SSE endpoint for real-time updates
return debugmonitor.HandleSSEStream(c, store)
case "data":
// JSON endpoint for polling mode
return debugmonitor.HandleDataJSON(c, store)
default:
return echo.NewHTTPError(http.StatusBadRequest)
}
},
}

wrapper := &LoggerWrapper{
original: logger,
original: config.Logger,
monitor: m,
}

Expand Down
Loading