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 recorded payloads are auto-escaped (a stored-XSS defense) even when they contain untrusted data:
mux.Handle("/telescope/", http.StripPrefix("/telescope",
rec.Handler(telescope.HandlerOptions{})))
The Handler is deliberately auth-agnostic: it exposes SQL, bindings, request IPs, log context and stack traces to anyone who can reach it. Never mount it publicly. Gate it with RequireBasicAuth, your own app middleware, or simply do not mount it in production. See docs/TELESCOPE.md for the prod-safe wiring.
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 ¶
- Constants
- func ContextWithRequestID(ctx context.Context, id string) context.Context
- func RequestIDFromContext(ctx context.Context) (string, bool)
- func RequireBasicAuth(username, password string, h http.Handler) http.Handler
- type CacheEntry
- type Entry
- type ExceptionEntry
- type HandlerOptions
- type JobEntry
- type LogEntry
- type MailEntry
- type Options
- type QueryEntry
- type Recorder
- func (r *Recorder) Capacity() int
- func (r *Recorder) Entries() []Entry
- func (r *Recorder) Filter(typ Type, limit int) []Entry
- func (r *Recorder) Find(id string) (Entry, bool)
- func (r *Recorder) Handler(opts HandlerOptions) http.Handler
- func (r *Recorder) Middleware() func(http.Handler) http.Handler
- func (r *Recorder) Record(e Entry) Entry
- func (r *Recorder) RecordCache(ctx context.Context, c CacheEntry) Entry
- func (r *Recorder) RecordException(ctx context.Context, ex ExceptionEntry) Entry
- func (r *Recorder) RecordJob(ctx context.Context, j JobEntry) Entry
- func (r *Recorder) RecordLog(ctx context.Context, l LogEntry) Entry
- func (r *Recorder) RecordMail(ctx context.Context, m MailEntry) Entry
- func (r *Recorder) RecordQuery(ctx context.Context, q QueryEntry) Entry
- func (r *Recorder) RecordRequest(ctx context.Context, req RequestEntry) Entry
- func (r *Recorder) Reset()
- type RequestEntry
- type Type
Examples ¶
Constants ¶
const DefaultCapacity = 500
DefaultCapacity is the number of entries retained when Options.Capacity is not set.
Variables ¶
This section is empty.
Functions ¶
func ContextWithRequestID ¶
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 ¶
RequestIDFromContext extracts the active telescope request id, if any.
func RequireBasicAuth ¶ added in v0.26.0
RequireBasicAuth wraps h so that every request must carry HTTP Basic credentials matching username and password before the wrapped handler runs. It is the documented, dependency-free way to gate the dashboard when it must be reachable on a network that is not already private.
The handler returned by Handler is intentionally auth-agnostic so it can be mounted behind whatever middleware an application already uses (session auth, an IP allowlist, a reverse proxy, a VPN). When none of those apply, wrap it:
dash := rec.Handler(telescope.HandlerOptions{})
mux.Handle("/telescope/", http.StripPrefix("/telescope",
telescope.RequireBasicAuth("ops", os.Getenv("TELESCOPE_PASSWORD"), dash)))
Credentials are compared in constant time to avoid leaking their length or content through timing. An empty username and password disables the guard and panics at construction, so a misconfigured deployment fails loudly instead of silently exposing the dashboard.
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 ¶
PrettyPayload renders the payload as indented JSON for the detail view. It returns an empty string when there is nothing to show.
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 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 (*Recorder) Filter ¶
Filter selects stored entries newest-first. An empty typ matches all types; limit <= 0 means no limit.
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 ¶
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 ¶
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) RecordMail ¶
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.
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" )