Skip to content

feat(agents): issue-59-add-observability-and-alerting-for-mctl - #61

Merged
mashkovd merged 5 commits into
mainfrom
feat/agents-issue-59-add-observability-and-alerting-for-mctl
May 18, 2026
Merged

mashkovd merged 5 commits into
mainfrom
feat/agents-issue-59-add-observability-and-alerting-for-mctl

Conversation

@mashkovd

Copy link
Copy Markdown
Member

Implements accepted proposal mctl-telegram/issue-59-add-observability-and-alerting-for-mctl.

Spec: https://github.com/mctlhq/mctl-gitops/tree/main/platform-gitops/agents-state/mctl-telegram/proposals/issue-59-add-observability-and-alerting-for-mctl/

Generated by mctl-agents Tier 2 implementer. Review carefully — this PR was opened automatically and the spec may have gaps.

Closes #59

Add a Prometheus metrics layer to mctl-telegram covering HTTP requests,
auth failures, rate-limit events, MCP tool invocations and latency,
Telegram client pool size and errors, and session lifecycle counters.

- New internal/metrics package with Registry backed by a fresh (non-global)
  prometheus.Registry; HTTPMiddleware records method/route/status_code using
  chi route patterns to avoid high-cardinality raw-path labels.
- METRICS_ALLOW_CIDR env var gates /metrics to a CIDR range; when unset
  the endpoint is open for Kubernetes PodMonitor scraping.
- auth.Middleware gains a *metrics.Registry parameter and classifies JWT
  failure reasons (jwt_expired, jwt_invalid_signature, jwt_invalid_issuer,
  jwt_missing_audience, jwt_wrong_audience, bearer_scheme_error, other).
- audit.RateLimiter gains WithMetrics chaining; increments
  mctl_rate_limit_events_total{identity_kind} on HTTP 429.
- mcp.Server gains WithMetrics; s.audit() gains startedAt time.Time so
  every tool handler observes mctl_tool_invocation_duration_seconds and
  increments mctl_tool_invocations_total{tool, status}.
- telegram.ClientPool gains WithMetrics; increments/decrements
  mctl_telegram_client_pool_size in acquire/run and increments
  mctl_telegram_client_errors_total on non-context-canceled exit.
- db.Store gains WithMetrics; SaveSession increments
  mctl_sessions_connected_total; RevokeActiveSession gains a reason
  parameter ("disconnect", "idle_expiry", "absolute_expiry") and increments
  mctl_sessions_revoked_total{reason}; HardDeleteAccount increments
  mctl_sessions_revoked_total{reason="delete"}.
- SweepExpiredSessions split into SweepIdleSessions and
  SweepAbsoluteSessions with per-reason metric increments; sweeper updated
  to call both in sequence.
- CountActiveSessions DB query added; active session sampler goroutine in
  main() refreshes mctl_sessions_active every 60s.
- All wiring added to cmd/server/main.go; /metrics route mounted.
- Tests added: metrics/metrics_test.go, metrics/middleware_test.go; all
  existing tests updated for the RevokeActiveSession signature change.

Proposal: platform-gitops/agents-state/mctl-telegram/proposals/issue-59-add-observability-and-alerting-for-mctl/
@claude

claude Bot commented May 18, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 3m 54s —— View job


PR Review — feat(agents): observability and alerting for mctl

  • Gather context and read changed files
  • Review new metrics package
  • Review middleware changes
  • Review db/store changes
  • Review auth/config/other changes
  • Post inline findings
  • Post top-level summary

2 P2 findings, 4 P3 findings. Changes requested before merge.

Findings

Severity File Issue
P2 cmd/server/main.go:234 CIDR guard bypassable — middleware.RealIP rewrites r.RemoteAddr from X-Forwarded-For before metricsHandler reads it; attacker-controlled header bypasses the allowlist
P2 internal/db/store.go:405 HardDeleteAccount increments SessionsRevokedTotal by all deleted rows, including already-revoked ones; inflates the metric
P2 internal/db/store.go:578 No unit tests for SweepIdleSessions, SweepAbsoluteSessions, CountActiveSessions — all contain non-trivial time-arithmetic SQL
P3 cmd/server/main.go:107 CountActiveSessions error silently dropped; stale gauge has no observable signal
P3 internal/db/store.go:633 SessionsActiveGauge help text says "within the last hour" but last_used_at IS NULL rows are also counted
P3 internal/auth/middleware.go:44 classifyAuthError matches on error message substrings from other packages; silent regression if those strings change

What looks good

  • Non-global prometheus.Registry per instance: correct pattern, prevents test-collision panics.
  • responseWriter wrapper correctly captures status before Write() is called; promoted Flush() from the embedded http.ResponseWriter means SSE works without a separate Flusher wrapper.
  • Rate-limiter and auth middleware CIDR/metrics wiring is clean and nil-safe throughout.
  • RevokeActiveSession now takes a reason string; all call sites updated consistently.
  • New sweep methods (SweepIdleSessions / SweepAbsoluteSessions) correctly split the old single sweep so each reason can be labeled independently.
  • metricsHandler CIDR parse-error fallback is correct (open endpoint + warn, not panic/refuse).
  • Dependency additions (prometheus/client_golang, prometheus/client_model) are appropriate; no unnecessary transitive bloat.

Comment thread cmd/server/main.go Outdated
return h.ServeHTTP
}
return func(w http.ResponseWriter, r *http.Request) {
host, _, splitErr := net.SplitHostPort(r.RemoteAddr)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — CIDR guard bypassable via X-Forwarded-For spoofing

middleware.RealIP is applied earlier in the global middleware stack and rewrites r.RemoteAddr from the X-Forwarded-For / X-Real-IP headers. Any unauthenticated caller can send X-Forwarded-For: 10.0.0.1 and the CIDR check passes.

To guard against this, either:

  1. Mount /metrics on a separate internal listener that never runs RealIP, or
  2. Record the raw TCP IP in a context value before RealIP runs (first middleware) and read that value here instead of r.RemoteAddr.

The current guard should not be relied on for access control. Fix this →

Comment thread internal/db/store.go Outdated
}
rows, _ := res.RowsAffected()
if rows > 0 && s.metrics != nil {
s.metrics.SessionsRevokedTotal.WithLabelValues("delete").Add(float64(rows))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — HardDeleteAccount double-counts already-revoked sessions

HardDeleteAccount deletes all telegram_accounts rows for the user regardless of revoked_at. rows therefore includes rows that were already revoked before this call, and SessionsRevokedTotal{reason="delete"} will be inflated by those already-counted rows.

Fix: query only rows where revoked_at IS NULL to count what is actually being newly revoked, or add a WHERE revoked_at IS NULL sub-count before the delete. Fix this →

Comment thread internal/db/store.go
// idle TTL (30 days). Returns the number of rows flipped. Increments
// mctl_sessions_revoked_total{reason="idle_expiry"} if a metrics registry is
// wired.
func (s *Store) SweepIdleSessions(ctx context.Context) (int64, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — No unit tests for the three new store methods

SweepIdleSessions, SweepAbsoluteSessions, and CountActiveSessions contain non-trivial SQL and time-arithmetic logic that can vary between SQLite (local-dev) and Postgres (production). The existing store_test.go file already has an in-process SQLite harness — these methods should have coverage there. Missing tests for at least:

  • SweepIdleSessions revokes rows past idle TTL and ignores others.
  • SweepAbsoluteSessions revokes rows past absolute TTL.
  • CountActiveSessions excludes revoked rows and rows older than one hour.

Fix this →

Comment thread cmd/server/main.go Outdated
return
case <-ticker.C:
n, err := store.CountActiveSessions(ctx)
if err == nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — Errors from CountActiveSessions are silently dropped

When CountActiveSessions fails the gauge is simply not updated, but no log entry is written. A persistent DB error would leave the gauge stale with no observable signal. Add a slog.Warn or similar on err != nil. Fix this →

Comment thread internal/db/store.go
err := s.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM telegram_accounts
WHERE revoked_at IS NULL
AND (last_used_at IS NULL OR last_used_at > $1)`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — Metric help text contradicts query semantics for last_used_at IS NULL

The help string says "last_used_at is within the last hour", but the WHERE clause also matches rows where last_used_at IS NULL (sessions connected but never borrowed). Either:

  • Update the help to say "non-revoked sessions active or connected within the last hour (last_used_at IS NULL counts as active)", or
  • Remove last_used_at IS NULL from the count if the intent is strictly "used within the last hour".

@@ -31,6 +42,50 @@ func Middleware(p Provider, required bool) func(http.Handler) http.Handler {
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — String-matching on error messages is fragile

classifyAuthError matches against literal substrings like "JWT expired" and "invalid JWT signature" that are sourced from other packages. If those messages are ever renamed the classification silently degrades to "other" with no compile-time or test-time signal. Consider instead exporting sentinel error values (e.g. var ErrJWTExpired = errors.New("JWT expired")) from the provider packages and using errors.Is here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

// Connected=false if none. Used by GET /api/account.
func (s *Store) GetActiveAccount(ctx context.Context, userID int64) (*AccountInfo, error) {
var (

P2 Badge Count only active rows as delete revocations

When a user deletes their account after previous disconnects or reconnects, HardDeleteAccount deletes historical rows that were already revoked, but this code adds every deleted row to mctl_sessions_revoked_total{reason="delete"}. That makes the revocation metric overcount deletes for users with session history; count only non-revoked rows before deleting, or use a separate deletion counter for all removed rows.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +43 to +47
type responseWriter struct {
http.ResponseWriter
status int
wroteHeader bool
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve streaming interfaces in metrics wrapper

Because this middleware is installed globally, it also wraps the /mcp Streamable HTTP handler; this custom writer only exposes http.ResponseWriter, so downstream code that needs http.Flusher/http.ResponseController for text/event-stream no longer sees the server's streaming capabilities. In that MCP/SSE path the handler can fail to flush or reject streaming even though the underlying writer supports it, so the wrapper should forward optional interfaces (or use a wrapper that implements Unwrap/Flush) while capturing the status.

Useful? React with 👍 / 👎.

Comment thread cmd/server/main.go Outdated
Comment on lines +234 to +238
host, _, splitErr := net.SplitHostPort(r.RemoteAddr)
if splitErr != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the socket peer for metrics CIDR checks

In deployments that set METRICS_ALLOW_CIDR and also run the existing RealIP middleware, r.RemoteAddr may already have been replaced from client-controlled forwarding headers before this handler runs. A public caller can then spoof X-Forwarded-For/X-Real-IP to an address inside the allowed CIDR and scrape /metrics, so the allowlist should be based on the actual connection peer or run before trusting forwarded headers.

Useful? React with 👍 / 👎.

Comment thread internal/mcp/tools.go
Comment on lines +198 to 201
startedAt := time.Now()
id := auth.From(ctx)
if id == nil {
return mcplib.NewToolResultError("authentication required"), nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count tool validation failures in metrics

For prepare_send_message calls where the identity or required arguments are missing, these returns happen before the only metrics path (s.audit) is reached, so mctl_tool_invocations_total and the duration histogram omit those failed invocations entirely; the same early-return pattern exists in other tool handlers. If alerts rely on tool error rates, malformed or unauthenticated tool traffic will be invisible rather than counted with status="error".

Useful? React with 👍 / 👎.

Comment thread cmd/server/main.go Outdated
mux.Use(middleware.RealIP)
mux.Use(middleware.Recoverer)
mux.Use(middleware.Timeout(60 * time.Second))
mux.Use(m.HTTPMiddleware())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record panics in HTTP request metrics

With the middleware order shown here, the metrics wrapper is inside middleware.Recoverer; when a downstream handler panics, control unwinds out of HTTPMiddleware before its post-next.ServeHTTP counter increment runs, and then Recoverer writes the 500 response outside the metrics wrapper. Those requests disappear from mctl_http_requests_total instead of being counted as 500s, which undermines alerting on server errors.

Useful? React with 👍 / 👎.

Comment on lines +26 to +28
if m != nil {
m.AuthFailuresTotal.WithLabelValues(classifyAuthError(err.Error()), providerLabel).Inc()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count missing bearer tokens as auth failures

This increments mctl_auth_failures_total only when the provider returns an error, but on required routes a request with no Authorization header returns the 401 path below with no metric at all. In production MCP/account/bridge-token traffic, unauthenticated requests are still authentication failures for alerting purposes, so spikes of missing bearer tokens will be invisible unless this branch records a reason such as missing_credentials.

Useful? React with 👍 / 👎.

- middleware: the responseWriter wrapper now passes through http.Flusher
  and http.Hijacker. The middleware is mounted globally, so it also wraps
  the MCP endpoint (Streamable HTTP / SSE, needs Flush) and the /bridge
  websocket (needs Hijack for the upgrade) — masking those interfaces
  would break streaming and websocket upgrades.
- metricsHandler: a misconfigured METRICS_ALLOW_CIDR now fails CLOSED
  (rejects every request) instead of silently serving /metrics openly.
  Documented that the CIDR guard is defense-in-depth over a NetworkPolicy
  since it matches the RealIP-rewritten, spoofable RemoteAddr.
- mctl_sessions_active help text and CountActiveSessions doc corrected:
  the query also counts freshly created, never-used sessions. Noted the
  partial index that keeps the once-a-minute query cheap at scale.
@mashkovd

Copy link
Copy Markdown
Member Author

Independent review done; findings addressed in e69a9ca:

  • Flusher/Hijacker (critical) — the global responseWriter wrapper now passes through http.Flusher and http.Hijacker, so it no longer masks streaming on /mcp (Streamable HTTP / SSE) or the /bridge websocket upgrade.
  • P2 — /metrics fail-open — a malformed METRICS_ALLOW_CIDR now fails closed (rejects all) instead of serving metrics openly.
  • P2 — CIDR header spoofing — documented as defense-in-depth; the authoritative control is a NetworkPolicy (the CIDR matches the RealIP-rewritten RemoteAddr).
  • P2 — mctl_sessions_active — help text + CountActiveSessions doc corrected (the query also counts freshly created, never-used sessions); noted the partial index for scale.
  • P2 — classifyAuthError substring matching — left as-is: a typed-sentinel refactor would require changes across the auth provider packages with its own regression risk; the label is best-effort and the literals are stable.

go build ./..., go vet ./..., go test ./... all pass.

@claude

claude Bot commented May 18, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 2m 53s —— View job


Re-review — e69a9ca follow-up commit

  • Read changed files in follow-up commit
  • Verify previous findings addressed
  • Check for new issues
  • Post inline comments
  • Post summary

Summary

e69a9ca addressed 5 of the 8 original findings. 2 P2s and 1 P3 remain open.

Addressed Finding
✅ Fixed Flusher/Hijacker pass-through — responseWriter now correctly implements both interfaces; SSE and websocket upgrade unblocked
✅ Fixed /metrics fail-open — invalid METRICS_ALLOW_CIDR now rejects all requests (403) instead of serving metrics openly
✅ Documented CIDR guard spoofability — correctly documented as defense-in-depth over a NetworkPolicy
✅ Fixed mctl_sessions_active help text — updated to describe last_used_at IS NULL semantics accurately
✅ Accepted classifyAuthError substring matching — intentionally left as-is; noted as best-effort label

Remaining — still requires changes before merge:

Severity Location Issue
P2 internal/db/store.go:405 HardDeleteAccount increments SessionsRevokedTotal{reason="delete"} by all deleted rows including already-revoked ones; inflates the metric. Fix: count WHERE revoked_at IS NULL before the delete.
P2 internal/db/store_test.go No unit tests for SweepIdleSessions, SweepAbsoluteSessions, or CountActiveSessions. All three contain non-trivial time-arithmetic SQL and the in-process SQLite harness is already in place.
P3 cmd/server/main.go:107 CountActiveSessions error branch has no log; stale gauge has no observable signal under persistent DB failure. One slog.Warn call fixes this.

No new issues were introduced by e69a9ca.

Comment thread internal/db/store.go Outdated
}
rows, _ := res.RowsAffected()
if rows > 0 && s.metrics != nil {
s.metrics.SessionsRevokedTotal.WithLabelValues("delete").Add(float64(rows))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 (carried over, not addressed) — HardDeleteAccount still inflates SessionsRevokedTotal{reason="delete"}

The DELETE FROM telegram_accounts WHERE user_id = $1 deletes every row for the user — active and already-revoked. RowsAffected() therefore counts rows that were already revoked before this call, so the metric double-counts historical revocations as new delete events.

Fix by counting only the active (not-yet-revoked) rows before the delete:

Suggested change
s.metrics.SessionsRevokedTotal.WithLabelValues("delete").Add(float64(rows))
rows, _ := res.RowsAffected()
if rows > 0 && s.metrics != nil {
var active int64
_ = s.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM telegram_accounts WHERE user_id = $1 AND revoked_at IS NULL`,
userID,
).Scan(&active)
if active > 0 {
s.metrics.SessionsRevokedTotal.WithLabelValues("delete").Add(float64(active))
}
}

Wait — that races with the DELETE already executed. The correct fix is to count before the delete, or add a WHERE revoked_at IS NULL sub-query to get the count in the same statement as the delete. A safe single-pass approach:

var active int64
_ = s.DB.QueryRowContext(ctx,
    `SELECT COUNT(*) FROM telegram_accounts WHERE user_id = $1 AND revoked_at IS NULL`,
    userID,
).Scan(&active)
res, err := s.DB.ExecContext(ctx, `DELETE FROM telegram_accounts WHERE user_id = $1`, userID)
...
if active > 0 && s.metrics != nil {
    s.metrics.SessionsRevokedTotal.WithLabelValues("delete").Add(float64(active))
}

Comment thread cmd/server/main.go
n, err := store.CountActiveSessions(ctx)
if err == nil {
m.SessionsActiveGauge.Set(float64(n))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 (carried over, not addressed) — CountActiveSessions error silently dropped

When the query fails the gauge is left stale with no observable signal. A persistent DB error would suppress all future updates with no log entry. Add a warn log in the error branch:

Suggested change
}
n, err := store.CountActiveSessions(ctx)
if err == nil {
m.SessionsActiveGauge.Set(float64(n))
} else {
slog.Warn("active session count failed", "err", err)
}

Comment thread internal/db/store_test.go
}

had, err := s.RevokeActiveSession(ctx, uid)
had, err := s.RevokeActiveSession(ctx, uid, "disconnect")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 (carried over, not addressed) — No unit tests for SweepIdleSessions, SweepAbsoluteSessions, or CountActiveSessions

These three methods contain non-trivial time-arithmetic SQL that is easy to get wrong (off-by-one on < vs <=, wrong cutoff direction, IS NULL edge cases). The existing in-process SQLite harness in newTestStore can exercise all three. At minimum:

  • SweepIdleSessions: seed one row with last_used_at past the idle TTL and one within it; assert only the expired row is revoked.
  • SweepAbsoluteSessions: seed one row with expires_at in the past and one in the future; assert only the expired row is revoked.
  • CountActiveSessions: seed a revoked row, a never-used row (last_used_at IS NULL), and a stale row (last_used_at older than 1h); assert the count is correct.

Without these, SQLite/Postgres time-function divergence or a future refactor can silently break the sweep logic.

…ty-and-alerting-for-mctl

- [P1] metrics/middleware.go: add Unwrap() to responseWriter so
  http.ResponseController (Go 1.20+) can discover interfaces on the
  underlying writer through the wrapper layer.
- [P1] cmd/server/main.go: store the real TCP socket peer address in
  context via http.Server.ConnContext and read it in metricsHandler for
  the CIDR allowlist check, preventing X-Forwarded-For header spoofing.
- [P2] cmd/server/main.go: move m.HTTPMiddleware() before
  middleware.Recoverer so panics converted to HTTP 500 are recorded in
  mctl_http_requests_total.
- [P2] mcp/tools.go: call s.audit() on id==nil and empty-argument
  validation failures in toolPrepareSendMessage so those paths are
  counted in mctl_tool_invocations_total.
- [P2] auth/middleware.go: increment mctl_auth_failures_total with
  reason=no_token when id==nil and auth is required (missing bearer).

Proposal: platform-gitops/agents-state/mctl-telegram/proposals/issue-59-add-observability-and-alerting-for-mctl/
@claude

claude Bot commented May 18, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 3m 30s —— View job


Re-review — a6358d7 follow-up commit

  • Read changed files in follow-up commit
  • Verify previous P2/P3 findings addressed
  • Check for new issues
  • Post inline comments
  • Post summary

a6358d7 addressed all Codex P1/P2 findings. 2 P2s and 1 P3 from prior reviews remain open. No new issues introduced.

What a6358d7 fixed

Finding Status
Middleware ordering — HTTPMiddleware moved before Recoverer; panics now recorded as 500 in metrics ✅ Fixed
CIDR spoofing — ConnContext stores real TCP peer; metricsHandler reads socketPeerKey instead of r.RemoteAddr ✅ Fixed
Missing bearer token not counted — no_token label added to AuthFailuresTotal ✅ Fixed
MCP tool early-return paths not audited — s.audit() added before both early returns in prepare_send_message ✅ Fixed
ResponseController compatibility — Unwrap() added to responseWriter ✅ Fixed

Still requires changes before merge

Severity Location Issue
P2 internal/db/store.go:405 HardDeleteAccount increments SessionsRevokedTotal{reason="delete"} using RowsAffected() which counts all deleted rows including already-revoked ones — inflates the metric. Fix: count WHERE revoked_at IS NULL in a separate query before the DELETE.
P2 internal/db/store_test.go No unit tests for SweepIdleSessions, SweepAbsoluteSessions, or CountActiveSessions after three commits. The in-process SQLite harness is ready; these methods contain time-arithmetic SQL that is easy to get wrong.
P3 cmd/server/main.go:109 CountActiveSessions error silently dropped; add one slog.Warn call.

Comment thread internal/db/store.go Outdated
}
rows, _ := res.RowsAffected()
if rows > 0 && s.metrics != nil {
s.metrics.SessionsRevokedTotal.WithLabelValues("delete").Add(float64(rows))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 (carried over, still not addressed) — HardDeleteAccount inflates SessionsRevokedTotal{reason="delete"}

DELETE FROM telegram_accounts WHERE user_id = $1 removes all rows for the user — active and already-revoked. RowsAffected() therefore includes rows whose revoked_at was already set, so this metric increment double-counts historical revocations as new delete events.

Fix: count only non-revoked rows before the delete, then use that count for the metric:

Suggested change
s.metrics.SessionsRevokedTotal.WithLabelValues("delete").Add(float64(rows))
rows, _ := res.RowsAffected()
if rows > 0 && s.metrics != nil {
var active int64
_ = s.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM telegram_accounts WHERE user_id = $1 AND revoked_at IS NULL`,
userID,
).Scan(&active)
if active > 0 {
s.metrics.SessionsRevokedTotal.WithLabelValues("delete").Add(float64(active))
}
}

Wait — that races with the already-executed DELETE. The safe approach is to run the COUNT before the DELETE:

var active int64
_ = s.DB.QueryRowContext(ctx,
    `SELECT COUNT(*) FROM telegram_accounts WHERE user_id = $1 AND revoked_at IS NULL`,
    userID,
).Scan(&active)
res, err := s.DB.ExecContext(ctx, `DELETE FROM telegram_accounts WHERE user_id = $1`, userID)
// ... error check ...
if active > 0 && s.metrics != nil {
    s.metrics.SessionsRevokedTotal.WithLabelValues("delete").Add(float64(active))
}

TestHardDeleteAccount_RemovesAllRows seeds one already-revoked row and one active row yet does not assert metric values, so this bug passes tests silently.

Comment thread internal/db/store_test.go
}

had, err := s.RevokeActiveSession(ctx, uid)
had, err := s.RevokeActiveSession(ctx, uid, "disconnect")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 (carried over, still not addressed) — No unit tests for SweepIdleSessions, SweepAbsoluteSessions, or CountActiveSessions

All three methods contain non-trivial time-arithmetic SQL and remain without test coverage after three commits. The in-process SQLite harness (newTestStore) is already in place and can exercise them directly.

Minimum cases needed:

  • SweepIdleSessions: one row with last_used_at past the idle TTL, one within; assert only the expired row is revoked and the returned count is 1.
  • SweepAbsoluteSessions: one row with expires_at in the past, one in the future; assert only the expired row is revoked.
  • CountActiveSessions: one revoked row, one never-used row (last_used_at IS NULL), one stale row (last_used_at older than 1h); assert the returned count is correct.

These three SQL expressions are where SQLite/Postgres time-function divergence or a future refactor can silently break sweep logic — test coverage is load-bearing here.

Comment thread cmd/server/main.go
n, err := store.CountActiveSessions(ctx)
if err == nil {
m.SessionsActiveGauge.Set(float64(n))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 (carried over, still not addressed) — CountActiveSessions error silently dropped

When the query fails, the gauge is left stale with no log entry and no observable signal. A persistent DB failure would suppress all future gauge updates silently.

Suggested change
}
n, err := store.CountActiveSessions(ctx)
if err == nil {
m.SessionsActiveGauge.Set(float64(n))
} else {
slog.Warn("active session count failed", "err", err)
}

- HardDeleteAccount counts only rows that were still active at delete
  time toward mctl_sessions_revoked_total{reason="delete"}. It previously
  used RowsAffected(), inflating the counter with already-revoked rows.
  The active-row count and the delete now run in one transaction.
- Add unit tests for SweepIdleSessions, SweepAbsoluteSessions and
  CountActiveSessions (time-arithmetic paths were untested), plus a test
  pinning the HardDeleteAccount delete-metric fix.
@claude

claude Bot commented May 18, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 2m 8s —— View job


Re-review — d2b2327 follow-up commit

  • Read changed files in follow-up commit
  • Verify previous P2/P3 findings addressed
  • Check for new issues
  • Post inline comments
  • Post summary

d2b2327 fixed both outstanding P2s. 1 P3 remains.

What d2b2327 fixed

Finding Status
P2 — HardDeleteAccount inflates SessionsRevokedTotal{reason="delete"} ✅ Fixed — wraps count and delete in a transaction; counts only revoked_at IS NULL rows before deleting; uses that count for the metric. Regression test TestHardDeleteAccount_DeleteMetricCountsOnlyActiveRows pins the fix.
P2 — No unit tests for SweepIdleSessions, SweepAbsoluteSessions, CountActiveSessions ✅ Fixed — three new tests added, covering time-arithmetic edge cases (already-revoked exclusion, idle vs. absolute TTL, NULL last_used_at counting).

Remaining — 1 P3

Severity Location Issue
P3 cmd/server/main.go:109 CountActiveSessions error silently dropped; stale gauge has no observable signal under persistent DB failure. One slog.Warn call fixes this.

What looks good in d2b2327

  • The transaction approach in HardDeleteAccount is the right fix: the COUNT(*) and DELETE see the same snapshot, eliminating the TOCTOU race that a pre-delete query outside a transaction would have.
  • TestSweepIdleSessions uses 40-day-old vs. 1-day-old rows against the 30-day idleSessionTTL constant — the margin is safe and not brittle.
  • TestCountActiveSessions correctly asserts that both a recently-used row and a last_used_at IS NULL row count toward the gauge, matching the updated help text.
  • No new issues introduced.

No P1 or P2 findings. The single P3 does not block merge but should be fixed in the same pass.

Comment thread cmd/server/main.go
n, err := store.CountActiveSessions(ctx)
if err == nil {
m.SessionsActiveGauge.Set(float64(n))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 (carried over, still not addressed) — CountActiveSessions error silently dropped

When the query fails the gauge is left stale with no log entry. Add one warn line:

Suggested change
}
n, err := store.CountActiveSessions(ctx)
if err == nil {
m.SessionsActiveGauge.Set(float64(n))
} else {
slog.Warn("active session count failed", "err", err)
}

…rting-for-mctl

Resolve conflicts from PR #60 (unauthorized-session detection + tool-call
logging) against the observability layer:
- internal/mcp/tools.go audit(): keep both the metrics block (#61) and the
  slog mirror block (#60); the get_unread_messages bridge path keeps the
  RedactPeer wrapper (#60) and gains startedAt (#61).
- RevokeActiveSession now takes a reason — pass it at the three #60 call
  sites (store.go partial-session and clientpool revokeRejected ->
  "unauthorized"; revoke_telegram_session tool -> "disconnect").
- The two #60 admin tools (revoke_telegram_session, get_user_audit_log)
  capture startedAt and pass it to audit() for the duration metric.

Also fix the remaining P3: log a slog.Warn when CountActiveSessions fails
so a stale mctl_sessions_active gauge leaves a trail.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@claude

claude Bot commented May 18, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 3m 2s —— View job


Re-review — e723eb2 merge commit

  • Read changed files after merge
  • Verify P3 finding addressed (CountActiveSessions error logging)
  • Check for new issues from the merge
  • Post inline comments
  • Post summary

No P1 or P2 findings. All previously open findings are addressed. Good to merge.

What e723eb2 fixed

Finding Status
P3 — CountActiveSessions error silently dropped ✅ Fixed — slog.Warn("active-session gauge sample failed", "err", err) added at cmd/server/main.go:110

Merge conflict resolution (correct)

The merge integrated PR #60 (unauthorized-session detection + tool-call logging) against the observability layer cleanly:

  • get_unread_messages bridge audit path: correctly keeps telegram.RedactPeer from fix: detect unauthorized sessions and log MCP tool calls #60 and gains startedAt from this PR — no peer cleartext in audit rows.
  • RevokeActiveSession(reason) signature: all three new call sites from fix: detect unauthorized sessions and log MCP tool calls #60 (partial-session → "unauthorized", clientpool.revokeRejected → "unauthorized", revoke_telegram_session tool → "disconnect") pass a labelled reason, so the metric is correctly bucketed.
  • New admin tools (revoke_telegram_session, get_user_audit_log) correctly capture startedAt and pass it to s.audit() for duration histogramming.

Full finding status across all review rounds

Severity Finding Status
P1 Flusher/Hijacker masked by global responseWriter ✅ Fixed (e69a9ca)
P1 /metrics fail-open on invalid CIDR ✅ Fixed (e69a9ca)
P1 Middleware ordering — metrics wrapper inside Recoverer ✅ Fixed (a6358d7)
P1 CIDR spoofable via X-Forwarded-For ✅ Fixed (a6358d7 — ConnContext + socketPeerKey)
P1 Missing bearer token not counted in AuthFailuresTotal ✅ Fixed (a6358d7 — no_token label)
P1 MCP tool early-return paths not audited ✅ Fixed (a6358d7)
P1 ResponseController compatibility (Unwrap()) ✅ Fixed (a6358d7)
P2 HardDeleteAccount inflates SessionsRevokedTotal{reason="delete"} ✅ Fixed (d2b2327 — transaction + pre-delete COUNT(*) WHERE revoked_at IS NULL)
P2 No unit tests for SweepIdleSessions, SweepAbsoluteSessions, CountActiveSessions ✅ Fixed (d2b2327 — three tests added)
P3 CountActiveSessions error silently dropped ✅ Fixed (e723eb2 — slog.Warn)
P3 mctl_sessions_active help text incorrect ✅ Fixed (e69a9ca)
P3 classifyAuthError substring matching Accepted as intentional; documented

No new issues introduced by the merge.

@mashkovd
mashkovd merged commit bb767b1 into main May 18, 2026
5 checks passed
@mashkovd
mashkovd deleted the feat/agents-issue-59-add-observability-and-alerting-for-mctl branch May 18, 2026 23:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add observability and alerting for mctl-telegram

2 participants