REPOSITORY / ScuroNeko/Laniakea

Wiki

KNOWLEDGE REPOSITORY
1
Sentry Integration
ScuroNeko edited this page 2026-07-09 18:13:22 +03:00

Sentry Integration

Russian version: Sentry-Integration-RU

Laniakea does not ship a Sentry integration, and intentionally so — see Error-Handling for the classified-error model and Runners / Policies for the other event sources. The Observer interface is already the extension point for this: it receives every classified handler, scene, runner, and policy error with rich context (plugin, handler kind, chat/user IDs, update ID), and dispatch is panic-safe, so a failing exporter cannot crash the bot.

This page is a copy-paste starting point, not a bundled package. Sentry tagging conventions and noise filtering are opinionated enough that you'll likely want to adjust them for your bot, so keeping this out of the module avoids forcing sentry-go into every consumer's dependency graph for a shape that rarely fits as-is.

What to report

  • OnError fires for every error routed through the centralized handler/scene error flow. Skip IsUserError(err) results — those are expected, already answered in chat, and not bugs.
  • OnPolicyChecked fires for every policy evaluation, including ordinary "not allowed" denials. Only the Internal ones (misconfiguration, failed API lookups) are worth reporting.
  • OnPollingRetry is not an incident by itself, but makes a good breadcrumb leading up to a later error.

Example

package observability

import (
	"context"

	"github.com/getsentry/sentry-go"

	laniakea "git.scuroneko.dev/scuroneko/laniakea"
)

// SentryObserver forwards unexpected framework errors to Sentry. All hooks
// besides OnError, OnPolicyChecked and OnPollingRetry are no-ops — they
// exist only to satisfy laniakea.Observer.
type SentryObserver struct{}

var _ laniakea.Observer = SentryObserver{}

func (SentryObserver) OnUpdateReceived(context.Context, laniakea.UpdateReceivedEvent)   {}
func (SentryObserver) OnUpdateHandled(context.Context, laniakea.UpdateHandledEvent)     {}
func (SentryObserver) OnHandlerStarted(context.Context, laniakea.HandlerStartedEvent)   {}
func (SentryObserver) OnHandlerFinished(context.Context, laniakea.HandlerFinishedEvent) {}
func (SentryObserver) OnSceneTransition(context.Context, laniakea.SceneTransitionEvent) {}
func (SentryObserver) OnRunnerFinished(context.Context, laniakea.RunnerFinishedEvent)   {}

// OnPollingRetry leaves a breadcrumb instead of an event — a single retry
// isn't an incident, but it's useful context if a real error follows.
func (SentryObserver) OnPollingRetry(ctx context.Context, e laniakea.PollingRetryEvent) {
	sentry.GetHubFromContext(ctx).AddBreadcrumb(&sentry.Breadcrumb{
		Category: "polling",
		Message:  e.Err.Error(),
		Level:    sentry.LevelWarning,
		Data: map[string]any{
			"attempt": e.Attempt,
			"delay":   e.Delay.String(),
		},
	}, nil)
}

// OnPolicyChecked reports only internal policy failures (misconfiguration,
// failed API lookups) — a plain "not allowed" denial is expected behavior.
func (SentryObserver) OnPolicyChecked(ctx context.Context, e laniakea.PolicyCheckedEvent) {
	if e.Passed || e.Err == nil || !e.Internal {
		return
	}
	captureLaniakeaError(ctx, e.Err, map[string]string{
		"source": "policy",
		"policy": e.Name,
		"plugin": e.Plugin,
	}, e.FromID, e.ChatID, 0)
}

// OnError is the main entry point: every handler/scene/runner error routed
// through the framework's classified-error flow lands here.
func (SentryObserver) OnError(ctx context.Context, e laniakea.ErrorEvent) {
	if e.Err == nil || laniakea.IsUserError(e.Err) {
		return // already surfaced to the user via ctx.Error, not a bug report
	}
	captureLaniakeaError(ctx, e.Err, map[string]string{
		"source":       "handler",
		"plugin":       e.Plugin,
		"handler_kind": string(e.HandlerKind),
		"handler_name": e.HandlerName,
		"update_type":  string(e.UpdateType),
	}, e.FromID, e.ChatID, e.UpdateID)
}

func captureLaniakeaError(ctx context.Context, err error, tags map[string]string, fromID, chatID int64, updateID int) {
	hub := sentry.GetHubFromContext(ctx)
	if hub == nil {
		hub = sentry.CurrentHub().Clone()
	}
	hub.WithScope(func(scope *sentry.Scope) {
		scope.SetTags(tags)
		scope.SetContext("telegram", map[string]any{
			"chat_id":   chatID,
			"from_id":   fromID,
			"update_id": updateID,
		})
		hub.CaptureException(err)
	})
}

Wiring it up

sentry.Init(sentry.ClientOptions{Dsn: dsn})
defer sentry.Flush(2 * time.Second)

bot.SetObserver(observability.SentryObserver{})

bot.SetObserver(...) replaces the observer wholesale — see Bot-Options-and-Configuration. If you need Sentry alongside metrics or logging instrumentation, write a small fan-out Observer that dispatches to several sub-observers, each wrapped in its own recover() so a panic in one exporter does not skip the others for the same event.