hub v0.43.0: remote app-log diagnostics — copyable issues + context + on-demand log tails + range/dismissal fixes

- store: app_log_issues gains context/context_customer (first capture wins) + dismissed_at (resurface only on last_seen > dismissed_at); log_tail_requests (pending operator intents, consume-once) + app_log_tails (transient, keep last 2 per app)
- api: /report ingests log_tails (stores + clears the request); ACK advertises log_tail_requests (same additive omit-when-empty pattern as escrow)
- web: Known Issues rows click-to-expand (full copyable message + context with provenance + explicit affected-customers list); Dismiss replaces Delete; period selector now filters issues (F); ?customer= filtered view + customer-page drill-down links (H); per-app Request-log-tail button + pending badge + App Log Tails section + ordered tail view with line numbers + .log download; customer-visible log_tail_requested event
- tests: store (context first-capture/late-adopt, range filter, dismissal old-window vs new-occurrence, tail request/fulfill/prune/scoping), api ACK round-trip, web render (expanded row, customer page sections, tail view + download + cross-customer 404)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-10 16:03:32 +02:00
parent 0cc70a7a5a
commit c084046af0
15 changed files with 1180 additions and 44 deletions
+139
View File
@@ -0,0 +1,139 @@
package store
import (
"database/sql"
"encoding/json"
"time"
)
// On-demand app log tails (Part D) — the pull-based counterpart of the controller's
// log_tails report section. The hub stores an operator intent in log_tail_requests,
// advertises it on the report ACK (log_tail_requests: [app…]), and when the tail lands
// on the NEXT report it is stored here and the request is cleared (consume-once).
// Tails are transient custody: the last 2 per (customer, app) are kept, older pruned.
// AppLogTail is one received, ordered log tail.
type AppLogTail struct {
ID int
CustomerID string
AppName string
CollectedAt time.Time
ReceivedAt time.Time
Lines []string
}
// RequestLogTail records (or refreshes) the operator's pending request for one app's
// log tail. One active request per (customer, app) — a re-click refreshes requested_at.
func (s *Store) RequestLogTail(customerID, appName string) error {
_, err := s.db.Exec(`
INSERT INTO log_tail_requests (customer_id, app_name, requested_at)
VALUES (?, ?, ?)
ON CONFLICT(customer_id, app_name) DO UPDATE SET requested_at = excluded.requested_at`,
customerID, appName, time.Now().UTC())
return err
}
// GetPendingLogTailRequests returns the app names with a pending tail request for the
// customer — the value advertised on the report ACK. nil/empty = nothing pending.
func (s *Store) GetPendingLogTailRequests(customerID string) ([]string, error) {
rows, err := s.db.Query(`SELECT app_name FROM log_tail_requests WHERE customer_id = ? ORDER BY app_name`, customerID)
if err != nil {
return nil, err
}
defer rows.Close()
var apps []string
for rows.Next() {
var app string
if err := rows.Scan(&app); err == nil {
apps = append(apps, app)
}
}
return apps, rows.Err()
}
// SaveAppLogTail stores a received tail, prunes to the newest 2 per (customer, app),
// and clears the pending request (consume-once — without the clear the ACK would keep
// advertising the request and the controller would ship the tail every cycle).
func (s *Store) SaveAppLogTail(customerID, appName string, collectedAt time.Time, lines []string) error {
linesJSON, err := json.Marshal(lines)
if err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`
INSERT INTO app_log_tails (customer_id, app_name, collected_at, received_at, lines_json)
VALUES (?, ?, ?, ?, ?)`,
customerID, appName, collectedAt, time.Now().UTC(), string(linesJSON)); err != nil {
return err
}
// Keep the newest 2 per (customer, app).
if _, err := tx.Exec(`
DELETE FROM app_log_tails
WHERE customer_id = ? AND app_name = ? AND id NOT IN (
SELECT id FROM app_log_tails WHERE customer_id = ? AND app_name = ?
ORDER BY id DESC LIMIT 2
)`, customerID, appName, customerID, appName); err != nil {
return err
}
// Consume-once: the fulfilled request is cleared.
if _, err := tx.Exec(`DELETE FROM log_tail_requests WHERE customer_id = ? AND app_name = ?`,
customerID, appName); err != nil {
return err
}
return tx.Commit()
}
// GetCustomerLogTails returns all stored tails for a customer, newest first.
func (s *Store) GetCustomerLogTails(customerID string) ([]AppLogTail, error) {
rows, err := s.db.Query(`
SELECT id, customer_id, app_name, collected_at, received_at, lines_json
FROM app_log_tails WHERE customer_id = ?
ORDER BY id DESC`, customerID)
if err != nil {
return nil, err
}
defer rows.Close()
var tails []AppLogTail
for rows.Next() {
t, err := scanLogTail(rows)
if err != nil {
continue
}
tails = append(tails, t)
}
return tails, rows.Err()
}
// GetLogTail returns one stored tail by id, scoped to the customer (no cross-customer reads).
func (s *Store) GetLogTail(id int, customerID string) (*AppLogTail, error) {
rows, err := s.db.Query(`
SELECT id, customer_id, app_name, collected_at, received_at, lines_json
FROM app_log_tails WHERE id = ? AND customer_id = ?`, id, customerID)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, rows.Err()
}
t, err := scanLogTail(rows)
if err != nil {
return nil, err
}
return &t, nil
}
func scanLogTail(rows *sql.Rows) (AppLogTail, error) {
var t AppLogTail
var linesJSON string
if err := rows.Scan(&t.ID, &t.CustomerID, &t.AppName, &t.CollectedAt, &t.ReceivedAt, &linesJSON); err != nil {
return t, err
}
json.Unmarshal([]byte(linesJSON), &t.Lines)
return t, nil
}
+36
View File
@@ -440,6 +440,42 @@ func (s *Store) migrate() error {
return err
}
// v0.43.0 — remote app-log diagnostics. Additive columns on app_log_issues:
// context = JSON array of ±5 redacted lines around the FIRST occurrence (first capture
// wins — stable repro context, no churn); context_customer = whose box it came from
// (provenance); dismissed_at = dismissal instead of futile deletion (the controller
// re-sends recurring issues — a delete is undone minutes later; a dismissal stays until
// a genuinely NEW occurrence with last_seen > dismissed_at resurfaces the row).
s.db.Exec(`ALTER TABLE app_log_issues ADD COLUMN context TEXT`)
s.db.Exec(`ALTER TABLE app_log_issues ADD COLUMN context_customer TEXT`)
s.db.Exec(`ALTER TABLE app_log_issues ADD COLUMN dismissed_at DATETIME`)
// log_tail_requests: the operator's pending "send me this app's logs" intents — the
// pull-based ACK flag (the hub NEVER connects into a box; the controller sees the flag
// in its report ACK and ships the tail on its next cycle). One active request per
// (customer, app) — a re-click refreshes requested_at. Cleared when the tail arrives
// (consume-once). app_log_tails: the received tails, transient — keep the last 2 per
// (customer, app), older pruned at insert.
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS log_tail_requests (
customer_id TEXT NOT NULL,
app_name TEXT NOT NULL,
requested_at DATETIME NOT NULL,
PRIMARY KEY (customer_id, app_name)
);
CREATE TABLE IF NOT EXISTS app_log_tails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id TEXT NOT NULL,
app_name TEXT NOT NULL,
collected_at DATETIME NOT NULL,
received_at DATETIME NOT NULL,
lines_json TEXT NOT NULL
);
`)
if err != nil {
return err
}
return nil
}
+96 -15
View File
@@ -32,6 +32,9 @@ type AppTelemetryRecord struct {
Message string `json:"message"`
Count int `json:"count"`
LastSeen time.Time `json:"last_seen"`
// Context (controller v0.111.0+): ±5 redacted lines around the first occurrence.
// Absent on older controllers — nil-safe everywhere.
Context []string `json:"context,omitempty"`
} `json:"issues,omitempty"`
}
@@ -94,6 +97,13 @@ type AppIssue struct {
LastSeen time.Time
OccurrenceCount int
AffectedCustomers []string
// Context: redacted lines around the FIRST captured occurrence (first capture wins);
// ContextCustomer records whose box it came from (provenance). Empty on pre-v0.111 data.
Context []string
ContextCustomer string
// DismissedAt: non-nil = operator-dismissed; hidden from the default view until a
// genuinely NEW occurrence (last_seen > dismissed_at) resurfaces it.
DismissedAt *time.Time
}
// SaveAppTelemetry inserts telemetry records for a customer report into the database.
@@ -136,7 +146,7 @@ func (s *Store) SaveAppTelemetry(customerID string, reportedAt time.Time, record
// Upsert log issues
for _, issue := range r.Issues {
fp := fingerprintIssue(issue.Message)
if err := upsertAppIssue(tx, r.AppName, fp, issue.Severity, issue.Message, customerID, issue.LastSeen); err != nil {
if err := upsertAppIssue(tx, r.AppName, fp, issue.Severity, issue.Message, customerID, issue.LastSeen, issue.Context); err != nil {
s.logger.Printf("[WARN] upsertAppIssue %s/%s: %v", r.AppName, fp[:min(len(fp), 20)], err)
}
}
@@ -146,15 +156,32 @@ func (s *Store) SaveAppTelemetry(customerID string, reportedAt time.Time, record
}
// upsertAppIssue inserts or updates a known log issue record.
func upsertAppIssue(tx *sql.Tx, appName, fingerprint, severity, message, customerID string, lastSeen time.Time) error {
//
// Context: stored on INSERT; on conflict only adopted when the stored context is still
// empty (first capture wins — stable repro context, no churn). context_customer moves
// with it (provenance).
//
// Dismissal: a dismissed row is resurrected ONLY by a genuinely NEW occurrence
// (excluded.last_seen > dismissed_at) — a re-sent old window stays hidden, but a
// recurrence is never silently swallowed.
func upsertAppIssue(tx *sql.Tx, appName, fingerprint, severity, message, customerID string, lastSeen time.Time, context []string) error {
contextJSON := ""
if len(context) > 0 {
if b, err := json.Marshal(context); err == nil {
contextJSON = string(b)
}
}
// Try insert first
_, err := tx.Exec(`
INSERT INTO app_log_issues (app_name, fingerprint, severity, message, first_seen, last_seen, occurrence_count, affected_customers)
VALUES (?, ?, ?, ?, ?, ?, 1, ?)
INSERT INTO app_log_issues (app_name, fingerprint, severity, message, first_seen, last_seen, occurrence_count, affected_customers, context, context_customer)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
ON CONFLICT(app_name, fingerprint) DO UPDATE SET
last_seen = CASE WHEN excluded.last_seen > last_seen THEN excluded.last_seen ELSE last_seen END,
occurrence_count = occurrence_count + 1`,
appName, fingerprint, severity, message, lastSeen, lastSeen, `[]`)
occurrence_count = occurrence_count + 1,
dismissed_at = CASE WHEN dismissed_at IS NOT NULL AND excluded.last_seen > dismissed_at THEN NULL ELSE dismissed_at END,
context_customer = CASE WHEN (context IS NULL OR context = '') AND excluded.context != '' THEN excluded.context_customer ELSE context_customer END,
context = CASE WHEN (context IS NULL OR context = '') AND excluded.context != '' THEN excluded.context ELSE context END`,
appName, fingerprint, severity, message, lastSeen, lastSeen, `[]`, contextJSON, customerID)
if err != nil {
return err
}
@@ -394,15 +421,23 @@ func (s *Store) GetCustomerAppSummary(customerID string, since time.Time) ([]Cus
return result, nil
}
// GetAppIssues returns recent known issues for a specific app.
func (s *Store) GetAppIssues(appName string, limit int) ([]AppIssue, error) {
rows, err := s.db.Query(`
// GetAppIssues returns known issues for a specific app seen since the given time.
// The time filter is the SAME period the memory-trend chart uses (Part F fix: the
// 24h/7d/30d selector now applies to issues too). Dismissed rows are excluded unless
// includeDismissed is set.
func (s *Store) GetAppIssues(appName string, since time.Time, includeDismissed bool, limit int) ([]AppIssue, error) {
q := `
SELECT id, app_name, fingerprint, severity, message, first_seen, last_seen,
occurrence_count, affected_customers
occurrence_count, affected_customers, context, context_customer, dismissed_at
FROM app_log_issues
WHERE app_name = ?
WHERE app_name = ? AND last_seen >= ?`
if !includeDismissed {
q += ` AND dismissed_at IS NULL`
}
q += `
ORDER BY last_seen DESC
LIMIT ?`, appName, limit)
LIMIT ?`
rows, err := s.db.Query(q, appName, since, limit)
if err != nil {
return nil, err
}
@@ -411,12 +446,13 @@ func (s *Store) GetAppIssues(appName string, limit int) ([]AppIssue, error) {
return scanAppIssues(rows)
}
// GetRecentIssuesAllApps returns the most recently seen issues across all apps.
// GetRecentIssuesAllApps returns the most recently seen non-dismissed issues across all apps.
func (s *Store) GetRecentIssuesAllApps(limit int) ([]AppIssue, error) {
rows, err := s.db.Query(`
SELECT id, app_name, fingerprint, severity, message, first_seen, last_seen,
occurrence_count, affected_customers
occurrence_count, affected_customers, context, context_customer, dismissed_at
FROM app_log_issues
WHERE dismissed_at IS NULL
ORDER BY last_seen DESC
LIMIT ?`, limit)
if err != nil {
@@ -432,16 +468,61 @@ func scanAppIssues(rows *sql.Rows) ([]AppIssue, error) {
for rows.Next() {
var ai AppIssue
var affectedJSON string
var contextJSON, contextCustomer sql.NullString
var dismissedAt sql.NullTime
if err := rows.Scan(&ai.ID, &ai.AppName, &ai.Fingerprint, &ai.Severity, &ai.Message,
&ai.FirstSeen, &ai.LastSeen, &ai.OccurrenceCount, &affectedJSON); err != nil {
&ai.FirstSeen, &ai.LastSeen, &ai.OccurrenceCount, &affectedJSON,
&contextJSON, &contextCustomer, &dismissedAt); err != nil {
continue
}
json.Unmarshal([]byte(affectedJSON), &ai.AffectedCustomers)
if contextJSON.Valid && contextJSON.String != "" {
json.Unmarshal([]byte(contextJSON.String), &ai.Context)
}
if contextCustomer.Valid {
ai.ContextCustomer = contextCustomer.String
}
if dismissedAt.Valid {
t := dismissedAt.Time
ai.DismissedAt = &t
}
issues = append(issues, ai)
}
return issues, rows.Err()
}
// DismissAppIssues marks all non-dismissed issues of an app dismissed (Part G: dismissal
// over deletion — the controller re-sends recurring issues, so a delete is futile; a
// dismissed row stays hidden until a NEW occurrence resurfaces it via upsertAppIssue).
func (s *Store) DismissAppIssues(appName string) (int64, error) {
res, err := s.db.Exec(`UPDATE app_log_issues SET dismissed_at = ? WHERE app_name = ? AND dismissed_at IS NULL`,
time.Now().UTC(), appName)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// DismissAppIssuesByIDs marks specific issues dismissed by their IDs.
func (s *Store) DismissAppIssuesByIDs(ids []int) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
placeholders := make([]string, len(ids))
args := make([]interface{}, 0, len(ids)+1)
args = append(args, time.Now().UTC())
for i, id := range ids {
placeholders[i] = "?"
args = append(args, id)
}
query := "UPDATE app_log_issues SET dismissed_at = ? WHERE id IN (" + strings.Join(placeholders, ",") + ")"
res, err := s.db.Exec(query, args...)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// PruneAppTelemetry removes telemetry rows older than the given time.
func (s *Store) PruneAppTelemetry(before time.Time) (int64, error) {
res, err := s.db.Exec("DELETE FROM app_telemetry WHERE reported_at < ?", before)
+260
View File
@@ -0,0 +1,260 @@
package store
import (
"encoding/json"
"io"
"log"
"path/filepath"
"testing"
"time"
)
func newTelemetryTestStore(t *testing.T) *Store {
t.Helper()
s, err := New(filepath.Join(t.TempDir(), "telemetry.db"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("store.New: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
// mkTelemetryRecord builds an AppTelemetryRecord through JSON — the same path a real
// controller report takes (also proves nil-safety when context is absent entirely).
func mkTelemetryRecord(t *testing.T, appName, severity, message string, lastSeen time.Time, context []string) AppTelemetryRecord {
t.Helper()
issue := map[string]interface{}{
"severity": severity,
"message": message,
"count": 1,
"last_seen": lastSeen.UTC().Format(time.RFC3339),
}
if context != nil {
issue["context"] = context
}
b, _ := json.Marshal(map[string]interface{}{
"app_name": appName,
"issues": []interface{}{issue},
})
var rec AppTelemetryRecord
if err := json.Unmarshal(b, &rec); err != nil {
t.Fatalf("unmarshal record: %v", err)
}
return rec
}
func saveIssue(t *testing.T, s *Store, customer, app, severity, msg string, lastSeen time.Time, context []string) {
t.Helper()
rec := mkTelemetryRecord(t, app, severity, msg, lastSeen, context)
if err := s.SaveAppTelemetry(customer, time.Now(), []AppTelemetryRecord{rec}); err != nil {
t.Fatalf("SaveAppTelemetry: %v", err)
}
}
func getOneIssue(t *testing.T, s *Store, app string, since time.Time, includeDismissed bool) *AppIssue {
t.Helper()
issues, err := s.GetAppIssues(app, since, includeDismissed, 20)
if err != nil {
t.Fatalf("GetAppIssues: %v", err)
}
if len(issues) == 0 {
return nil
}
return &issues[0]
}
var longAgo = time.Now().Add(-365 * 24 * time.Hour)
// Part C — context stored on INSERT with provenance; a later empty-context upsert does
// not clobber; a later DIFFERENT context does not churn it (first capture wins).
func TestUpsertIssue_ContextFirstCaptureWins(t *testing.T) {
s := newTelemetryTestStore(t)
msg := "ERROR: nfs mount /mnt/media lost"
now := time.Now()
saveIssue(t, s, "cust-a", "cwa", "error", msg, now, []string{"before line", "ERROR: nfs mount /mnt/media lost", "after line"})
is := getOneIssue(t, s, "cwa", longAgo, false)
if is == nil {
t.Fatalf("issue not stored")
}
if len(is.Context) != 3 || is.Context[0] != "before line" {
t.Fatalf("context not stored on insert: %#v", is.Context)
}
if is.ContextCustomer != "cust-a" {
t.Fatalf("provenance = %q, want cust-a", is.ContextCustomer)
}
// A pre-v0.111 controller (no context field) re-reports the same issue: must not clobber.
saveIssue(t, s, "cust-b", "cwa", "error", msg, now.Add(time.Minute), nil)
is = getOneIssue(t, s, "cwa", longAgo, false)
if len(is.Context) != 3 {
t.Fatalf("empty-context upsert clobbered the stored context: %#v", is.Context)
}
if is.ContextCustomer != "cust-a" {
t.Fatalf("provenance churned to %q", is.ContextCustomer)
}
// both customers recorded
if len(is.AffectedCustomers) != 2 {
t.Fatalf("affected customers = %v, want both", is.AffectedCustomers)
}
// A different context from another box: first capture still wins (no churn).
saveIssue(t, s, "cust-b", "cwa", "error", msg, now.Add(2*time.Minute), []string{"other box context"})
is = getOneIssue(t, s, "cwa", longAgo, false)
if is.Context[0] != "before line" || is.ContextCustomer != "cust-a" {
t.Fatalf("first-capture-wins violated: ctx=%#v from %q", is.Context, is.ContextCustomer)
}
}
// Part C — an issue first seen WITHOUT context (old controller) adopts the first
// context that arrives later.
func TestUpsertIssue_LateContextAdopted(t *testing.T) {
s := newTelemetryTestStore(t)
msg := "ERROR: db locked"
saveIssue(t, s, "cust-a", "gokapi", "error", msg, time.Now(), nil)
is := getOneIssue(t, s, "gokapi", longAgo, false)
if len(is.Context) != 0 {
t.Fatalf("expected no context yet, got %#v", is.Context)
}
saveIssue(t, s, "cust-b", "gokapi", "error", msg, time.Now().Add(time.Minute), []string{"ctx line"})
is = getOneIssue(t, s, "gokapi", longAgo, false)
if len(is.Context) != 1 || is.Context[0] != "ctx line" || is.ContextCustomer != "cust-b" {
t.Fatalf("late context not adopted: %#v from %q", is.Context, is.ContextCustomer)
}
}
// Part F — the time-range filter: a 10-day-old issue is absent from the 24h view,
// present in the 30d view. Red-proof target: drop the last_seen >= since predicate →
// the 24h view shows the old row → this fails.
func TestGetAppIssues_RangeFilter(t *testing.T) {
s := newTelemetryTestStore(t)
tenDaysAgo := time.Now().Add(-10 * 24 * time.Hour)
saveIssue(t, s, "cust-a", "vault", "error", "ERROR: smtp down", tenDaysAgo, nil)
if got := getOneIssue(t, s, "vault", time.Now().Add(-24*time.Hour), false); got != nil {
t.Fatalf("10d-old issue visible in the 24h view (last_seen=%s)", got.LastSeen)
}
if got := getOneIssue(t, s, "vault", time.Now().Add(-30*24*time.Hour), false); got == nil {
t.Fatalf("10d-old issue missing from the 30d view")
}
}
// Part G — dismissal semantics: a re-sent OLD window (last_seen <= dismissed_at) stays
// hidden; a genuinely NEW occurrence (last_seen > dismissed_at) resurfaces the row.
// Red-proof: drop the `excluded.last_seen > dismissed_at` guard in upsertAppIssue (make
// the conflict always clear dismissed_at) → the old-window re-report resurrects → FAIL.
func TestDismiss_OldWindowHidden_NewOccurrenceResurfaces(t *testing.T) {
s := newTelemetryTestStore(t)
msg := "ERROR: cwa nfs stale handle"
oldSeen := time.Now().Add(-time.Hour)
saveIssue(t, s, "cust-a", "cwa", "error", msg, oldSeen, nil)
if n, err := s.DismissAppIssues("cwa"); err != nil || n != 1 {
t.Fatalf("DismissAppIssues = %d/%v, want 1", n, err)
}
if got := getOneIssue(t, s, "cwa", longAgo, false); got != nil {
t.Fatalf("dismissed issue still in the default view")
}
// visible with the toggle, flagged
if got := getOneIssue(t, s, "cwa", longAgo, true); got == nil || got.DismissedAt == nil {
t.Fatalf("show-dismissed view must expose the row with DismissedAt set")
}
// Old-window re-report (controller re-sends its rolling window with the SAME last_seen).
saveIssue(t, s, "cust-a", "cwa", "error", msg, oldSeen, nil)
if got := getOneIssue(t, s, "cwa", longAgo, false); got != nil {
t.Fatalf("old-window re-report RESURRECTED the dismissed issue (last_seen=%s)", got.LastSeen)
}
// A genuinely NEW occurrence — recurrence must never be silently swallowed.
newSeen := time.Now().Add(time.Minute)
saveIssue(t, s, "cust-a", "cwa", "error", msg, newSeen, nil)
got := getOneIssue(t, s, "cwa", longAgo, false)
if got == nil {
t.Fatalf("NEW occurrence did not resurface the dismissed issue")
}
if got.DismissedAt != nil {
t.Fatalf("resurfaced issue still flagged dismissed")
}
}
// Part D (store half) — request → pending; tail arrival stores, prunes to 2, and clears
// the request (consume-once). Red-proof: remove the request-DELETE in SaveAppLogTail →
// the pending request survives fulfillment → this fails.
func TestLogTail_RequestFulfillConsumeOnce(t *testing.T) {
s := newTelemetryTestStore(t)
if err := s.RequestLogTail("cust-a", "gokapi"); err != nil {
t.Fatalf("RequestLogTail: %v", err)
}
// re-click refreshes, still one request
if err := s.RequestLogTail("cust-a", "gokapi"); err != nil {
t.Fatalf("RequestLogTail re-click: %v", err)
}
apps, err := s.GetPendingLogTailRequests("cust-a")
if err != nil || len(apps) != 1 || apps[0] != "gokapi" {
t.Fatalf("pending = %v/%v, want [gokapi]", apps, err)
}
// another customer sees nothing
if other, _ := s.GetPendingLogTailRequests("cust-b"); len(other) != 0 {
t.Fatalf("cross-customer pending leak: %v", other)
}
lines := []string{"first line", "second line", "third line"}
if err := s.SaveAppLogTail("cust-a", "gokapi", time.Now(), lines); err != nil {
t.Fatalf("SaveAppLogTail: %v", err)
}
// consume-once: fulfilled request cleared
if apps, _ := s.GetPendingLogTailRequests("cust-a"); len(apps) != 0 {
t.Fatalf("request survived fulfillment: %v — the ACK would re-request every cycle", apps)
}
tails, err := s.GetCustomerLogTails("cust-a")
if err != nil || len(tails) != 1 {
t.Fatalf("tails = %d/%v, want 1", len(tails), err)
}
if len(tails[0].Lines) != 3 || tails[0].Lines[0] != "first line" || tails[0].Lines[2] != "third line" {
t.Fatalf("tail lines lost order: %#v", tails[0].Lines)
}
// keep-last-2 pruning
s.SaveAppLogTail("cust-a", "gokapi", time.Now(), []string{"tail-2"})
s.SaveAppLogTail("cust-a", "gokapi", time.Now(), []string{"tail-3"})
tails, _ = s.GetCustomerLogTails("cust-a")
if len(tails) != 2 {
t.Fatalf("keep-last-2 violated: %d tails stored", len(tails))
}
if tails[0].Lines[0] != "tail-3" || tails[1].Lines[0] != "tail-2" {
t.Fatalf("pruning kept the wrong tails: %v / %v", tails[0].Lines, tails[1].Lines)
}
// customer-scoped single read
if tail, _ := s.GetLogTail(tails[0].ID, "cust-b"); tail != nil {
t.Fatalf("cross-customer tail read must return nil")
}
if tail, _ := s.GetLogTail(tails[0].ID, "cust-a"); tail == nil || tail.AppName != "gokapi" {
t.Fatalf("scoped tail read failed: %+v", tail)
}
}
// Warn issues ride with no context and stay message-only end-to-end (nil-safe).
func TestUpsertIssue_WarnNoContext(t *testing.T) {
s := newTelemetryTestStore(t)
saveIssue(t, s, "cust-a", "app", "warn", "WARN: slow disk", time.Now(), nil)
is := getOneIssue(t, s, "app", longAgo, false)
if is == nil || len(is.Context) != 0 {
t.Fatalf("warn issue context = %#v, want none", is)
}
}
// Occurrence counting still works with the new upsert (regression guard).
func TestUpsertIssue_OccurrenceCount(t *testing.T) {
s := newTelemetryTestStore(t)
msg := "ERROR: boom"
for i := 0; i < 3; i++ {
saveIssue(t, s, "cust-a", "app", "error", msg, time.Now().Add(time.Duration(i)*time.Minute), nil)
}
is := getOneIssue(t, s, "app", longAgo, false)
if is.OccurrenceCount != 3 {
t.Fatalf("occurrence_count = %d, want 3", is.OccurrenceCount)
}
}