telescope

package
v0.24.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 24, 2026 License: MIT Imports: 11 Imported by: 0

Documentation

Overview

Package telescope provides a Laravel-Telescope-style in-process debug dashboard for the lagodev framework, built entirely on the Go standard library (net/http, html/template, sync, encoding/json, time).

The package is intentionally self-contained and decoupled from the rest of the framework. It never reaches into observability, orm, cache or mail internals; instead the application *pushes* what happened through a small Record* API and telescope stores it in a bounded, concurrency-safe ring buffer. This keeps telescope testable in isolation and lets it sit behind any router or middleware stack.

Entries

Everything telescope stores is an Entry: an immutable record with an ID, a Type (Request, Query, Job, Cache, Mail, Exception, Log), a timestamp, a set of Tags and a free-form payload map. Typed helpers build the well-known shapes:

rec := telescope.NewRecorder(telescope.Options{Capacity: 500})
rec.RecordQuery(ctx, telescope.QueryEntry{
	SQL:      "select * from users where id = ?",
	Bindings: []any{42},
	Duration: 3 * time.Millisecond,
})

HTTP middleware

Middleware times each request, assigns it a request id (propagated through the context so child entries can be correlated) and records a Request entry when the handler returns:

rec := telescope.NewRecorder(telescope.Options{})
srv := rec.Middleware()(mux)
http.ListenAndServe(":8080", srv)

Inside a handler, the active request id is available so that queries, cache hits and exceptions recorded during the request are tagged with it:

id, _ := telescope.RequestIDFromContext(r.Context())

Dashboard

Handler returns an http.Handler serving an HTML dashboard plus a small JSON API. Everything is rendered through html/template, so payloads are auto-escaped and the dashboard is safe to expose untrusted recorded data:

mux.Handle("/telescope/", http.StripPrefix("/telescope",
	rec.Handler(telescope.HandlerOptions{})))

Routes (relative to the mount point):

GET  /            HTML overview: counts per type + recent entries
GET  /{type}      HTML list of entries of one type
GET  /entry/{id}  HTML detail view with a pretty-printed payload
GET  /api/entries JSON entry list (honours ?type= and ?limit=)
POST /clear       clears every stored entry, then redirects to /

N+1 heuristic

RecordQuery normalises each statement (literals and bind placeholders are collapsed) and, when the same normalised statement is recorded more than once under the same request id, flags the duplicates with the "n+1" tag and a payload "n_plus_one" boolean. This surfaces the classic ORM lazy-loading problem without any ORM hook.

Example

Example shows a real user creating a Recorder, wrapping a handler with Middleware(), driving it with a single httptest request, and recording two identical (modulo bind value) queries under that request. It then prints deterministic facts: how many entries were stored, the recorded types, and whether the N+1 burst was flagged on the repeated query.

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"

	"github.com/devituz/lagodev/telescope"
)

func main() {
	rec := telescope.NewRecorder(telescope.Options{Capacity: 100})

	// The handler records two queries that normalise to the same statement,
	// correlated with the active request id so the N+1 heuristic can fire.
	app := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()
		rec.RecordQuery(ctx, telescope.QueryEntry{
			SQL:      "select * from posts where id = 1",
			Duration: time.Millisecond,
		})
		rec.RecordQuery(ctx, telescope.QueryEntry{
			SQL:      "select * from posts where id = 2",
			Duration: time.Millisecond,
		})
		w.WriteHeader(http.StatusOK)
	})

	// Wrap with Middleware: it records a Request entry when the handler returns.
	srv := httptest.NewServer(rec.Middleware()(app))
	defer srv.Close()

	resp, err := http.Get(srv.URL + "/posts")
	if err != nil {
		panic(err)
	}
	resp.Body.Close()

	// Inspect the recorded entries (newest first).
	all := rec.Entries()
	fmt.Println("total entries:", len(all))
	fmt.Println("request entries:", len(rec.Filter(telescope.TypeRequest, 0)))
	fmt.Println("query entries:", len(rec.Filter(telescope.TypeQuery, 0)))

	// The newest query (recorded second) is the N+1 repeat.
	queries := rec.Filter(telescope.TypeQuery, 0)
	repeat := queries[0]
	fmt.Println("repeat type:", repeat.Type)
	flagged, _ := repeat.Payload["n_plus_one"].(bool)
	fmt.Println("n+1 flagged:", flagged)
	fmt.Println("repeat count:", repeat.Payload["repeat_count"])

}
Output:
total entries: 3
request entries: 1
query entries: 2
repeat type: query
n+1 flagged: true
repeat count: 2

Index

Examples

Constants

View Source
const DefaultCapacity = 500

DefaultCapacity is the number of entries retained when Options.Capacity is not set.

Variables

This section is empty.

Functions

func ContextWithRequestID

func ContextWithRequestID(ctx context.Context, id string) context.Context

ContextWithRequestID returns a copy of ctx carrying id so that child entries recorded during a request can be correlated with the Request entry.

func RequestIDFromContext

func RequestIDFromContext(ctx context.Context) (string, bool)

RequestIDFromContext extracts the active telescope request id, if any.

Types

type CacheEntry

type CacheEntry struct {
	Operation string // "get", "set", "forget", ...
	Key       string
	Hit       bool
	Value     any
	Tags      []string
}

CacheEntry describes a cache operation.

type Entry

type Entry struct {
	// ID uniquely identifies the entry within a process. It is assigned by
	// the recorder if left empty.
	ID string `json:"id"`
	// Type classifies the entry.
	Type Type `json:"type"`
	// Time is when the underlying event happened. Defaulted to now if zero.
	Time time.Time `json:"time"`
	// RequestID correlates child entries (queries, cache, exceptions) with
	// the Request entry they occurred under. Empty when out of a request.
	RequestID string `json:"request_id,omitempty"`
	// Tags are short labels for filtering and quick scanning, e.g. "n+1",
	// "slow", "miss".
	Tags []string `json:"tags,omitempty"`
	// Payload holds the kind-specific fields. It is always JSON-serialisable.
	Payload map[string]any `json:"payload,omitempty"`
}

Entry is a single immutable record stored in the recorder's ring buffer. Typed Record* helpers build the well-known payload shapes, but Record accepts any Entry so callers can store custom kinds too.

func (Entry) PrettyPayload

func (e Entry) PrettyPayload() string

PrettyPayload renders the payload as indented JSON for the detail view. It returns an empty string when there is nothing to show.

func (Entry) Title

func (e Entry) Title() string

Title is a short human-readable summary used in the dashboard list and as the detail-page heading. It is best-effort and never panics on a missing payload field.

type ExceptionEntry

type ExceptionEntry struct {
	Err   error
	Class string // optional logical class/category
	Stack string // optional stack trace
	Tags  []string
}

ExceptionEntry describes a recorded error or panic.

type HandlerOptions

type HandlerOptions struct {
	// Title overrides the dashboard page title. Defaults to "Telescope".
	Title string
	// DefaultLimit caps how many entries the list and JSON API return when
	// the request does not specify ?limit=. Defaults to 200. A value <= 0
	// means no default limit.
	DefaultLimit int
}

HandlerOptions configures the dashboard handler.

type JobEntry

type JobEntry struct {
	Name     string
	Queue    string
	Status   string // e.g. "processed", "failed"
	Duration time.Duration
	Err      error
	Tags     []string
}

JobEntry describes a background/queued job execution.

type LogEntry

type LogEntry struct {
	Level   string // e.g. "info", "warn", "error"
	Message string
	Context map[string]any // optional structured fields
	Tags    []string
}

LogEntry describes an application log line.

type MailEntry

type MailEntry struct {
	From    string
	To      []string
	Subject string
	Mailer  string
	Tags    []string
}

MailEntry describes an outbound mail message.

type Options

type Options struct {
	// Capacity is the maximum number of entries kept in the ring buffer.
	// Older entries are evicted once it is full. Defaults to DefaultCapacity.
	Capacity int
	// Now overrides the clock; used by tests. Defaults to time.Now.
	Now func() time.Time
}

Options configures a Recorder.

type QueryEntry

type QueryEntry struct {
	SQL      string
	Bindings []any
	Duration time.Duration
	// Connection names the database connection, optional.
	Connection string
	Tags       []string
}

QueryEntry describes a database statement.

type Recorder

type Recorder struct {
	// contains filtered or unexported fields
}

Recorder collects entries into a bounded, concurrency-safe ring buffer and serves them to the dashboard. The zero value is not usable; build one with NewRecorder.

func NewRecorder

func NewRecorder(opts Options) *Recorder

NewRecorder returns a ready Recorder.

func (*Recorder) Capacity

func (r *Recorder) Capacity() int

Capacity reports the ring-buffer size.

func (*Recorder) Entries

func (r *Recorder) Entries() []Entry

Entries returns a snapshot of the stored entries, newest first.

func (*Recorder) Filter

func (r *Recorder) Filter(typ Type, limit int) []Entry

Filter selects stored entries newest-first. An empty typ matches all types; limit <= 0 means no limit.

func (*Recorder) Find

func (r *Recorder) Find(id string) (Entry, bool)

Find returns the entry with the given id, if present.

func (*Recorder) Handler

func (r *Recorder) Handler(opts HandlerOptions) http.Handler

Handler returns an http.Handler serving the dashboard and its JSON API. Mount it under a prefix, typically with http.StripPrefix:

mux.Handle("/telescope/", http.StripPrefix("/telescope",
	rec.Handler(telescope.HandlerOptions{})))

Routes (relative to the mount point):

GET  /              HTML overview: counts per type + recent entries
GET  /{type}        HTML list of entries of one type
GET  /entry/{id}    HTML detail view with a pretty-printed payload
GET  /api/entries   JSON list (honours ?type= and ?limit=)
POST /clear         clears every stored entry, then redirects to /

The Handler is safe for concurrent use.

func (*Recorder) Middleware

func (r *Recorder) Middleware() func(http.Handler) http.Handler

Middleware returns a net/http middleware that times each request, assigns it a telescope request id and records a Request entry once the wrapped handler returns. The request id is injected into the request context (via ContextWithRequestID) so any Record* calls made while handling the request are correlated with the Request entry, and it is mirrored onto the X-Request-ID response header.

An inbound X-Request-ID header is honoured when present so the id can be propagated across services; otherwise a fresh one is generated.

rec := telescope.NewRecorder(telescope.Options{})
srv := rec.Middleware()(mux)
http.ListenAndServe(":8080", srv)

func (*Recorder) Record

func (r *Recorder) Record(e Entry) Entry

Record stores e, filling in a generated ID and the current time when they are missing. It is safe for concurrent use. The stored entry's defaulted values are returned.

func (*Recorder) RecordCache

func (r *Recorder) RecordCache(ctx context.Context, c CacheEntry) Entry

RecordCache stores a Cache entry, tagging hits and misses for get operations.

func (*Recorder) RecordException

func (r *Recorder) RecordException(ctx context.Context, ex ExceptionEntry) Entry

RecordException stores an Exception entry. A nil Err is ignored and returns the zero Entry.

func (*Recorder) RecordJob

func (r *Recorder) RecordJob(ctx context.Context, j JobEntry) Entry

RecordJob stores a Job entry.

func (*Recorder) RecordLog

func (r *Recorder) RecordLog(ctx context.Context, l LogEntry) Entry

RecordLog stores a Log entry, tagging the entry with its level.

func (*Recorder) RecordMail

func (r *Recorder) RecordMail(ctx context.Context, m MailEntry) Entry

RecordMail stores a Mail entry.

func (*Recorder) RecordQuery

func (r *Recorder) RecordQuery(ctx context.Context, q QueryEntry) Entry

RecordQuery stores a Query entry. When the same normalised SQL has already been recorded under the same request id, the entry is flagged with the "n+1" tag and a payload "n_plus_one" boolean carrying the running count.

func (*Recorder) RecordRequest

func (r *Recorder) RecordRequest(ctx context.Context, req RequestEntry) Entry

RecordRequest stores a Request entry. The request id (from ctx, if present) is used both as the entry's RequestID and as a correlation tag.

A Request entry marks the end of a request lifecycle: all child entries (queries, cache, exceptions) recorded under the same id are already stored by the time the wrapping Middleware calls this. The per-request N+1 accumulator for that id is therefore dropped here so it cannot accumulate one stale map per request id over the life of the process.

func (*Recorder) Reset

func (r *Recorder) Reset()

Reset clears all stored entries and N+1 bookkeeping.

type RequestEntry

type RequestEntry struct {
	Method   string
	Path     string
	Status   int
	Duration time.Duration
	// IP is the remote address, optional.
	IP string
	// Tags are extra labels merged with any derived tags.
	Tags []string
}

RequestEntry describes an inbound HTTP request. Middleware fills it in, but it can also be recorded directly.

type Type

type Type string

Type classifies an Entry. The string values are stable and are used as the ?type= query-string filter on the dashboard and JSON API.

const (
	// TypeRequest is an inbound HTTP request handled by Middleware.
	TypeRequest Type = "request"
	// TypeQuery is a database statement.
	TypeQuery Type = "query"
	// TypeJob is a background/queued job execution.
	TypeJob Type = "job"
	// TypeCache is a cache get/set/hit/miss operation.
	TypeCache Type = "cache"
	// TypeMail is an outbound mail message.
	TypeMail Type = "mail"
	// TypeException is a recorded error or panic.
	TypeException Type = "exception"
	// TypeLog is an application log line.
	TypeLog Type = "log"
)

func (Type) Valid

func (t Type) Valid() bool

Valid reports whether t is one of the known entry types.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL