Documentation
¶
Overview ¶
Package telemetry ships Pando's logs to a remote Better Stack source, for users who want to share diagnostics with the maintainers. It is opt-in and off by default (see internal/config.TelemetryConfig).
This file holds the build-time secret plumbing. The Better Stack ingest source token is never committed and never stored in user configuration: it is injected at link time via -ldflags (see the Makefile's TELEMETRY_LDFLAGS and .goreleaser.yml), so a binary built without the secret (go install, `make build-fast`, a fork) simply reports telemetry as unavailable and the settings UI keeps the toggle disabled.
Local development: PANDO_BETTERSTACK_TOKEN=$(kvage get pando_betterstack_token) make build
Index ¶
- Constants
- func Available() bool
- func Endpoint() string
- func FormatDebugID(id string) string
- func NewDebugID() (string, error)
- func Token() string
- func ValidDebugID(id string) bool
- type AppInfo
- type Options
- type Record
- type Shipper
- func (s *Shipper) DebugID() string
- func (s *Shipper) Enabled(level slog.Level) bool
- func (s *Shipper) Enqueue(r Record)
- func (s *Shipper) Flush(ctx context.Context) error
- func (s *Shipper) Handle(ctx context.Context, t time.Time, level slog.Level, msg string, ...)
- func (s *Shipper) SetDebugID(id string)
- func (s *Shipper) Start(ctx context.Context)
- func (s *Shipper) Stats() Stats
- func (s *Shipper) Stop(ctx context.Context) error
- type Stats
Constants ¶
const ( DefaultQueueSize = 1000 DefaultBatchSize = 100 DefaultBatchBytes = 1 << 20 // 1 MiB DefaultFlushInterval = 5 * time.Second DefaultHTTPTimeout = 10 * time.Second )
Default tuning knobs used by Options.withDefaults when the caller leaves a field at its zero value. Matches the plan's "bounded channel (1000)... every 5s or at 100 records / 1 MiB" sizing.
const SkipAttrKey = "$_telemetry_skip"
SkipAttrKey marks a slog record that must never reach the telemetry sink. The shipper's own diagnostic logs (e.g. the one-time "shipping disabled" warning) carry it so a tee handler wired in front of the shipper does not re-ship them, which would otherwise create a feedback loop. A record carrying this key anywhere in its attrs is dropped by Shipper.Handle before it is even built. Matching is on the key's last dot-separated segment (see internal/logging's containsSkipAttr / isSkipAttrKey), so the guard still works when the key arrives prefixed by an open slog group (e.g. logger.WithGroup("g") turns it into "g.$_telemetry_skip").
Variables ¶
This section is empty.
Functions ¶
func Available ¶
func Available() bool
Available reports whether telemetry can actually be shipped right now:
- a source token is present (see Token — a custom endpoint never falls back to the built-in one, so this alone already enforces that rule);
- for a custom endpoint (PANDO_TELEMETRY_ENDPOINT set), the endpoint is either https, or plain http to a loopback address (127.0.0.1/localhost/::1) — the only case a plain-http endpoint is an acceptable trade-off is same-machine local development/testing (the Phase 6 E2E suite's http://127.0.0.1:<port> mock ingest server, specifically). A non-loopback http endpoint would send the token and every shipped record in the clear over the network, so it is treated as unavailable rather than silently allowed.
The default (official) endpoint has no such restriction: it is always https (see Endpoint), so this check is a no-op for the common case of an unmodified build with no PANDO_TELEMETRY_ENDPOINT override.
func Endpoint ¶
func Endpoint() string
Endpoint returns the full ingest URL log records are POSTed to. PANDO_TELEMETRY_ENDPOINT, when set, overrides it entirely (scheme included), so a local mock ingest server can be addressed over plain http during development and testing.
func FormatDebugID ¶
FormatDebugID renders a stored debug id (16 digits, no separators) grouped for display and copy-to-clipboard, e.g. "1234-5678-9012-3456". A value that is not exactly 16 digits — empty (never generated) or otherwise malformed — is returned unchanged rather than rendered as a confusing partial group.
func NewDebugID ¶
NewDebugID generates a new anonymous debug identifier: 16 random decimal digits, numeric so it is easy to dictate and paste into a support issue. The first digit is never zero, so the id never displays with a misleading leading zero. Each digit is drawn with crypto/rand.Int, which is unbiased by construction (it rejection-samples internally), so no digit is more likely than any other.
func Token ¶
func Token() string
Token returns the Better Stack source token used to authenticate ingest requests.
PANDO_TELEMETRY_TOKEN, when set, always overrides the build-time value — useful for local development and for self-hosted forks that want to inject their own token without a custom build. But when PANDO_TELEMETRY_ENDPOINT also overrides the default ingest host (a custom endpoint), the built-in build-time token is NEVER used as a fallback, even if PANDO_TELEMETRY_TOKEN is unset: sourceToken is a secret scoped to Pando's own official ingest host, and falling back to it here would send it to whatever arbitrary endpoint PANDO_TELEMETRY_ENDPOINT happens to point at (a misconfigured environment, a compromised one, or simply a self-hoster's own relay that was never meant to receive Pando's official token). A custom endpoint therefore requires its own PANDO_TELEMETRY_TOKEN; without one this returns empty, and Available() is false.
func ValidDebugID ¶
ValidDebugID reports whether id is exactly 16 decimal digits.
Types ¶
type AppInfo ¶
type AppInfo struct {
Version string `json:"version"`
Variant string `json:"variant,omitempty"`
OS string `json:"os"`
Arch string `json:"arch"`
Go string `json:"go"`
Mode string `json:"mode"`
}
AppInfo describes the running binary. It is attached to every Record.
type Options ¶
type Options struct {
// Endpoint is the full ingest URL POSTed to, e.g. "https://<host>".
Endpoint string
// Token is the Better Stack source token, sent as
// "Authorization: Bearer <Token>".
Token string
// DebugID is the anonymous identifier attached to every record. It can be
// changed live with Shipper.SetDebugID (e.g. after "Regenerate ID").
DebugID string
// Mode identifies the running surface: tui|serve|desktop|acp|cli.
Mode string
// MinLevel is the minimum slog level shipped; records below it are
// dropped before they ever reach the queue.
MinLevel slog.Level
// QueueSize bounds the number of records buffered between Enqueue and the
// worker goroutine. Enqueue never blocks: once full, new records are
// dropped and counted instead of waiting for room.
QueueSize int
// BatchSize is the max number of records per HTTP POST.
BatchSize int
// BatchBytes is the max marshaled size (bytes) per HTTP POST.
BatchBytes int
// FlushInterval is how often a partial batch is flushed even when it has
// not reached BatchSize/BatchBytes yet.
FlushInterval time.Duration
// HTTPTimeout bounds a single POST attempt.
HTTPTimeout time.Duration
// HTTPClient overrides the client used to POST batches. Mainly for
// tests; nil means a client with Timeout: HTTPTimeout is created.
HTTPClient *http.Client
// Gzip enables gzip-compressing the request body
// ("Content-Encoding: gzip"). Off by default until confirmed accepted by
// the ingest endpoint (see the plan's Phase 2 note).
Gzip bool
}
Options configures a Shipper. Endpoint/Token/DebugID/Mode are plain pass-through values the caller resolves beforehand (Endpoint/Token typically from Token()/Endpoint() in build.go, DebugID/Mode from the user's config and the running surface); everything else tunes the shipper's own batching and retry behavior.
type Record ¶
type Record struct {
Time time.Time `json:"dt"`
Level string `json:"level"`
Message string `json:"message"`
// DebugID is the anonymous debug id in the same dashed display format
// users see in the settings UI and copy into a support issue
// ("1234-5678-9012-3456"), not the raw undashed form config stores —
// this is the value a support search will actually be pasted, so
// storing/shipping it pre-formatted avoids a format mismatch between
// what the user has and what the record carries.
DebugID string `json:"debug_id,omitempty"`
App AppInfo `json:"app"`
Source string `json:"source,omitempty"`
SessionID string `json:"session_id,omitempty"`
Attrs map[string]any `json:"attrs,omitempty"`
// Dropped is the number of records dropped (queue full) since the last
// record that carried a nonzero Dropped count. The Shipper stamps this
// on the next record it pulls off the queue, not the constructor.
Dropped int `json:"dropped,omitempty"`
}
Record is one JSON log line shipped to Better Stack. Field names match the ingest API: "dt" is the RFC3339Nano timestamp Better Stack expects (time.Time's default JSON encoding already uses that layout).
func NewRecord ¶
NewRecord builds a Record from a raw slog call: it flattens attribute groups into dotted keys, and for every value:
- redacts it whole when its key looks like a secret (redact.IsSecretKey);
- otherwise converts it to a JSON-safe, recursively redacted value via redact.Value — which itself scrubs known secret patterns and the user's home directory out of every string, and round-trips any non-scalar value (a struct, a pointer, a typed map/slice, []byte, an error, a fmt.Stringer, ...) into the same safe shape rather than passing it through unredacted (see internal/redact.Value's doc);
- truncates every resulting string to maxAttrStringBytes.
The message is separately scrubbed and truncated to maxMessageBytes, and the whole attrs map is capped to maxAttrsBudgetBytes (see capAttrsBudget) so one record can never carry an unbounded amount of data regardless of how many/how large its individual attrs are.
As a convenience, a top-level "session_id" or "source" attr (no group prefix) is promoted to the Record's own SessionID/Source field instead of staying nested in Attrs, matching the documented record shape. DebugID, App and Dropped are left zero-valued: the Shipper fills those in from its own state when it builds a Record for shipping.
type Shipper ¶
type Shipper struct {
// contains filtered or unexported fields
}
Shipper batches Records and POSTs them to a Better Stack HTTP Logs source. It never blocks the caller: Enqueue drops records once the internal queue is full, and every network error is retried a bounded number of times before the batch is dropped. A Shipper is safe for concurrent use; all batching state lives in a single worker goroutine started by Start.
func NewShipper ¶
NewShipper builds a Shipper from opts, filling in defaults for any zero-valued tuning field. It does not start the background worker — call Start for that.
func (*Shipper) Enabled ¶
Enabled reports whether the shipper currently wants records at level: it is not disabled (by an ingest rejection or a recovered worker panic — see disable/recoverPanic) and level is at or above Options.MinLevel. It implements internal/logging.RemoteSink's Enabled method, so the tee handler can gate whether a record is even built/forwarded without relying solely on Handle's own internal check.
func (*Shipper) Enqueue ¶
Enqueue queues r for shipping. It never blocks: when the internal queue is full, r is dropped and counted instead, and the count is attached to the next record that is successfully queued (see Record.Dropped).
func (*Shipper) Flush ¶
Flush synchronously flushes whatever is currently batched (plus anything already queued), bounded by ctx. Used to push out a final record — e.g. a panic report — before the process exits.
func (*Shipper) Handle ¶
func (s *Shipper) Handle(ctx context.Context, t time.Time, level slog.Level, msg string, attrs []slog.Attr)
Handle is a convenience wrapper around NewRecord + Enqueue for slog integration: it honors Enabled (MinLevel + disabled) and the SkipAttrKey loop guard (matched on the key's last dot-separated segment, so a marker prefixed by an open slog group — "g.$_telemetry_skip" — still guards), and stamps DebugID/App from the shipper's own state.
func (*Shipper) SetDebugID ¶
SetDebugID updates the debug id attached to every record shipped from now on (e.g. after the user regenerates it). Safe for concurrent use.
func (*Shipper) Start ¶
Start launches the background worker that batches and ships records. ctx bounds the lifetime of in-flight HTTP calls made from the normal batching path (ticker/threshold flushes); Stop/Flush each carry their own ctx for their own flush.